Checking the person: sign_in:
On this page
Signing in has two steps. First Jadpo checks the person is who they say,
for example that they can read email sent to their address. Then it gives
them a session: a cookie for a browser, or an API token for your own
client. sign_in: in authentication { } lists the ways Jadpo checks a
person for you, so your app writes no sign-in code:
// authentication.jadpoauthentication { browser_session: { cookie: "notes_session" origin: config.browser_origin } api_token: {} sign_in: { magic_link: { address: Member.email link: "/sign-in/confirm" } providers: [google, github] } on_first_sign_in: accounts.create_member}
sign_in: holds up to three ways in:
magic_link:, an email with a link and a code (Magic links);passkey:, a key held on the person's device (Passkeys);providers:, sign-in with another service such as Google or GitHub.
Each way ends by starting a session in the same way.
on_first_sign_in: names an action in your app, here create_member in
the accounts domain. It decides what happens the first time someone
signs in whom your app has no user for, whichever way they came in, an
external token included. See
A person the app has not seen.
You never write a magic-link action. sessions.start is only for a person
your app checks some other way, such as one accepting an invitation it
sent (Starting and ending a session).
Providers
Each sign-in provider is a connection in app.jadpo's connections:
(External services), and providers: [...] names those
connections, as email: names its connection. Google, Microsoft and
other OpenID Connect providers are each driver: Driver.oidc, and the
provider's discovery document gives the rest. GitHub and Apple aren't
plain OpenID Connect, so they have the built-in drivers Driver.github
and Driver.apple.
// app.jadpoconnections: { google: { driver: Driver.oidc issuer: config.google_issuer client_id: config.google_client_id credential: config.google_client_secret }}
issuer:reads aUrlsetting. Jadpo compares it exactly with the issuer the provider's discovery document names, and with every ID token's. Google's ishttps://accounts.google.com. Microsoft's ishttps://login.microsoftonline.com/<tenant-id>/v2.0for one organisation's accounts, or the consumer tenant's,9188040d-6c67-4c5b-b112-36a304b66dad, for personal accounts. Itscommonandorganizationsaddresses publish an issuer with a placeholder in it, which no exact comparison matches, so they are refused asauthentication_misconfigured.client_id:reads aTextsetting: the client ID the provider gave your app. Every ID token must name it as its audience.credential:reads a secret setting: the client secret (Secrets).organisation:, optional, reads aTextsetting: the company whose accounts alone it signs in (Company accounts).- Register
<origin>/auth/<name>/callbackwith the provider as the redirect address,<origin>being the browser session'sorigin:.
SignInMethod has a value for each provider, its connection's name, so
on_first_sign_in:'s action can tell SignInMethod.google from
SignInMethod.microsoft.
A provider signs a person in to a browser session, so providers: needs
browser_session: with its origin:. It also needs on_first_sign_in:.
A provider account finds its user only through the link a first sign-in
made (A person the app has not seen),
never by the principal's subject: field or an email address, so without
the action no one could sign in.
Company accounts
A company's staff sign in with their work accounts. organisation: keeps
a provider to that company's accounts, so an account outside it, a
personal Gmail address among them, can't sign in or be linked:
// app.jadpoconnections: { google: { driver: Driver.oidc issuer: config.google_issuer client_id: config.google_client_id credential: config.google_client_secret organisation: config.google_domain }}
The setting is checked on every sign-in against the claim the provider's ID token names the account's company by:
- Google's
hd, the Google Workspace domain (desk.example), for the issuerhttps://accounts.google.com. Google's own page then offers only that domain's accounts. - Microsoft's
tid, the tenant ID, for a tenant's issuer,https://login.microsoftonline.com/<tenant-id>/v2.0. The issuer already names one tenant;tidis checked as well.
An account whose token names another company, or none, is refused with
sign_in_refused. A provider whose tokens name no company can't keep to
one, so organisation: on a connection whose issuer setting's default:
or test: is another provider's is connection.key_invalid; one whose
issuer is only known when the app starts is
authentication_misconfigured at sign-in.
The routes
Jadpo generates two routes for each provider, in the first API that accepts browser sessions:
POST /auth/<name>/startneeds no one signed in, but itsOriginmust be the browser session's origin. It takes{ "return_to": "/books" }, a path on your site, or an empty body for/. It answers{ "address": ... }, the provider's sign-in address, which your page sends the browser to. It also sets the<cookie>_providercookie (HttpOnly,Secure,SameSite=Lax, ten minutes), which binds the sign-in to this browser.GET /auth/<name>/callbackis where the provider sends the browser back. Jadpo checks the sign-in and starts a browser session. Then it answers303toreturn_to.
Every refusal also answers 303 to return_to, with ?error=<code>
after it, so your page can say what happened:
| Code | When |
|---|---|
sign_in_provider_failed |
The sign-in expired, was used already, came back to another browser, or the provider refused it or sent an ID token that doesn't check |
sign_in_refused |
on_first_sign_in: refused the person, or their account is outside the connection's organisation: |
user_disabled |
The person's user isn't active: |
authentication_unavailable |
The provider or the database can't be reached |
authentication_misconfigured |
The provider's settings or its discovery document are invalid |
Your page then reads the session with its own requests. The session's
cookie is SameSite=Strict, so a browser doesn't send it on the
navigation that came back from the provider.
What Jadpo checks
- The authorization code flow with PKCE (
S256), astatethat works once, for ten minutes, in the browser that started it, and anoncethe ID token must carry. Jadpo keeps thestateonly as a digest, and works out the PKCE verifier and the nonce from the binding cookie, so the database holds neither. - The code is exchanged at the provider's token endpoint with the client
secret. The ID token's signature is checked against the provider's
keys, fetched as an external token's are, under the same limits
(Where Jadpo fetches the provider's keys).
So are its exact issuer, its audience (
client_id:, withazpnaming it when there are several), its expiry and its nonce. - Only the token's
sub,email,email_verifiedandnameare read, intoFirstSignIn. None is ever stored as the user's identity, and no claim becomes a grant.address_verifiedistrueonly when the provider saysemail_verified: true. Microsoft doesn't say it, so an action can't link a Microsoft account by address.
In tests
A route test has test_providers, which signs in through the provider
routes for real, with the test run's local issuer as the provider. Only
tests can call it:
| Operation | Does |
|---|---|
test_providers.sign_in(method: SignInMethod, account: TestAccount) -> Response |
Starts the sign-in through POST /auth/<name>/start from the app's own page. Then the local issuer signs account in and sends the browser back to GET /auth/<name>/callback with the binding cookie. It gives the callback's Response, whose code is the error its redirect names, if any |
TestAccount holds what a provider says about the person: subject: Text, email: Email?, email_verified: Bool and name: Text?, and two
fields a test may leave out: organisation: Text?, the company its token
names (Company accounts), and strength: AuthenticationStrength?, multi_factor for a token whose amr names a
second factor. Only a test, fixture or seed builds one. The session it
starts records the provider, as a real sign-in's does.
Here a lending desk's staff sign in with Google. A member of staff is
taken on under their address, and on_first_sign_in: links the Google
account whose address Google has verified to them. The route test signs
Ann in through Google twice, the second time through the link, and shows
an unverified address and a Microsoft account refused:
use desk/entities { Librarian }application { display_name: "Lending desk" connections: { google: { driver: Driver.oidc issuer: config.google_issuer client_id: config.google_client_id credential: config.google_client_secret } microsoft: { driver: Driver.oidc issuer: config.microsoft_issuer client_id: config.microsoft_client_id credential: config.microsoft_client_secret } }}principal { user: { subject: Librarian.email active: Librarian.active == true }}
use deskauthentication { browser_session: { cookie: "desk_session" origin: config.site } sign_in: { providers: [google, microsoft] } on_first_sign_in: desk.link_staff}
use desk/entities { Librarian }use desk/failures { NotOnTheStaff }use desk/queries { staff_member }/// Links a Google account to the member of staff with its address, once/// Google has verified it.action link_staff(first: FirstSignIn) fails NotOnTheStaff -> Librarian.id { if (first.method != SignInMethod.google or not first.address_verified) { reject NotOnTheStaff {} } match first.address { none -> { reject NotOnTheStaff {} } some(address) -> { var librarian: Librarian = attempt staff_member(address) return librarian.id } }}
use desk/entities { Librarian }use desk/types { NewLibrarian }use desk/actions { take_on }test "staff sign in with Google under the address they were taken on with" { var ann: Librarian = attempt take_on( NewLibrarian { email: "ann@desk.example" name: "Ann" } ) var first: Response = test_providers.sign_in( SignInMethod.google, TestAccount { subject: "110248495921238986420" email: "ann@desk.example" email_verified: true name: "Ann Lee" } ) assert first.status == 303 assert first.code == none var second_sign_in: Response = test_providers.sign_in( SignInMethod.google, TestAccount { subject: "110248495921238986420" email: none email_verified: false name: none } ) assert second_sign_in.code == none var own_card: Response = request { method: HttpMethod.get path: "/staff/me" as: Principal.user(ann) } assert own_card.status == 200}test "an address Google hasn't verified signs no one in" { attempt take_on( NewLibrarian { email: "bo@desk.example" name: "Bo" } ) var refused: Response = test_providers.sign_in( SignInMethod.google, TestAccount { subject: "2001" email: "bo@desk.example" email_verified: false name: none } ) assert refused.status == 303 assert refused.code == "sign_in_refused"}test "a Microsoft account isn't linked by its address" { attempt take_on( NewLibrarian { email: "cy@desk.example" name: "Cy" } ) var refused: Response = test_providers.sign_in( SignInMethod.microsoft, TestAccount { subject: "AAAAAAAAAAAAAAAAAAAAAPv0" email: "cy@desk.example" email_verified: true name: "Cy" } ) assert refused.code == "sign_in_refused"}
In jadpo dev
jadpo dev never reaches the provider. Its local issuer
(External tokens in tests and jadpo dev)
stands in for every provider, so the provider's settings aren't needed:
any of issuer:, client_id: and credential: that nothing gives gets a
stand-in. POST /auth/<name>/start answers with the address of the
local issuer's account-chooser page, which jadpo dev serves on its own
port on your machine; the ready screen gives it on its sign-in line.
The page lists the provider's test accounts. Each is one of the
company's, when the connection has organisation:, and signs in with a
second factor, so the staff roles that need them can be tried. Choosing
one signs in as it, as the provider's own page would: the browser goes back to
GET /auth/<name>/callback with a code, and the route checks it as it
checks the provider's. Add a test account takes what a provider says
about a person, TestAccount's fields: a subject (made up in Google's
shape until you change it), an email, whether the provider verified it
and a name; adding one signs in as it. Cancel sign-in answers as a
refusal at the provider does, so the callback's redirect names
sign_in_provider_failed. The accounts are kept in
build/dev/accounts.json, by provider, for the next session.
The page works from the keyboard and a screen reader alone: each account is a button, each field has its label and its problem beside it, and the first account, or the first field with a problem, has the focus. It follows the system's light or dark setting, and runs no script.
The page takes only the sign-in the app's start route made: the provider must be one of the app's, and the client ID and the return address the app's own, or it says why and sends the browser nowhere. It answers only requests addressed to this machine by name, and a choice only from its own page, so another site can't sign you in. A deployable never serves it nor accepts its codes.
Not built yet: Driver.github and Driver.apple.
The old name
sign_in_providers: is refused, and the error renames it to sign_in:.
Markdown for agents: /docs/checking-the-person-sign-in.md.Built from Jadpo main at aee2e8f.