Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferenceSign-in and identity

APIsSign-in and identity

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.jadpo
// 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.jadpo
// app.jadpoconnections: {    google: {        driver: Driver.oidc        issuer: config.google_issuer        client_id: config.google_client_id        credential: config.google_client_secret    }}
  • issuer: reads a Url setting. Jadpo compares it exactly with the issuer the provider's discovery document names, and with every ID token's. Google's is https://accounts.google.com. Microsoft's is https://login.microsoftonline.com/<tenant-id>/v2.0 for one organisation's accounts, or the consumer tenant's, 9188040d-6c67-4c5b-b112-36a304b66dad, for personal accounts. Its common and organizations addresses publish an issuer with a placeholder in it, which no exact comparison matches, so they are refused as authentication_misconfigured.
  • client_id: reads a Text setting: 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 a Text setting: the company whose accounts alone it signs in (Company accounts).
  • Register <origin>/auth/<name>/callback with the provider as the redirect address, <origin> being the browser session's origin:.

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.jadpo
// 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 issuer https://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; tid is 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>/start needs no one signed in, but its Origin must 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>_provider cookie (HttpOnly, Secure, SameSite=Lax, ten minutes), which binds the sign-in to this browser.
  • GET /auth/<name>/callback is where the provider sends the browser back. Jadpo checks the sign-in and starts a browser session. Then it answers 303 to return_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), a state that works once, for ten minutes, in the browser that started it, and a nonce the ID token must carry. Jadpo keeps the state only 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:, with azp naming it when there are several), its expiry and its nonce.
  • Only the token's sub, email, email_verified and name are read, into FirstSignIn. None is ever stored as the user's identity, and no claim becomes a grant. address_verified is true only when the provider says email_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:

example
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    }}
example
use deskauthentication {    browser_session: {        cookie: "desk_session"        origin: config.site    }    sign_in: {        providers: [google, microsoft]    }    on_first_sign_in: desk.link_staff}
example
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        }    }}
example
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.