Building a web app
Register an OAuth2 client, sign a person in with the authorization code flow and PKCE, call services with the access token, and sign out.
A web app never sees a password. It sends the person to Hydra, the sign-in app
handles the rest, and the app gets back an access token for the person. This
page walks through that using the sample client flow-sample-web.
The sample web app, frontend/sample-app, does every
step on this page and is the reference to copy. It runs locally only, on
http://127.0.0.1:8765. Its README explains how to run it, how its cookies
work, and what a real app should do differently.
Read Sign-in and authorization first for how the pieces fit together.
Addresses
| Local | Dev | |
|---|---|---|
Hydra (issuer, /oauth2/...) | http://127.0.0.1:4444 | https://authn.dev.apps-and-services.com |
| Signing keys (JWKS) | http://127.0.0.1:4444/.well-known/jwks.json | https://authn.dev.apps-and-services.com/.well-known/jwks.json |
Locally, use 127.0.0.1 rather than localhost everywhere. The issuer is
compared as an exact string, and cookies set for one name are not sent to the
other. How to start the local stack is in services/authn/README.md.
Register an OAuth2 client
Every OAuth2 client: An app or service registered with Hydra so it can get access tokens. Web and mobile apps are public clients that use authorization code with PKCE; services use client_credentials. Each has exactly one audience. that signs a person in is defined in the repository, so every change to one is reviewed. There is one file per environment: services/authn/hydra/clients/dev.json and services/authn/hydra/clients/local.json. The deploy creates or updates each client in Hydra. This is the sample web app's entry:
{
"audience": ["https://api.flow.internal"],
"client_id": "flow-sample-web",
"client_name": "Flow sample web app",
"grant_types": ["authorization_code"],
"metadata": { "first_party": true },
"post_logout_redirect_uris": ["http://127.0.0.1:8765/logged-out"],
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"response_types": ["code"],
"scope": "openid",
"token_endpoint_auth_method": "none"
}A browser app is a public client: it cannot keep a secret, so
token_endpoint_auth_method is none and PKCE protects the code instead. The
main rules, which go test ./services/authn/... enforces:
client_idis lowercase letters, digits and-, and is never shaped like a UUID.- Exactly one
audience: the platform audience,https://api.flow.internal. Every service an app calls accepts it, identity included, so one token works for all of them. See docs/decisions/0055-platform-api-audience.md. - Redirect URIs are exact, with no wildcards. Plain http is allowed only as
http://127.0.0.1:<port>/..., and only indev.jsonandlocal.json. - No refresh tokens: no
refresh_tokengrant and nooffline_accessscope. A refresh token would outlive a sign-out. metadata.first_partyistrue. The sign-in app refuses consent for any other client.
The full list, and how to write the file in its required format, is under
OAuth2 clients in services/authn/README.md. The file must
be sorted and indented the way jq -S . writes it.
Send the person to Hydra
Generate a random state of at least 16 characters and a PKCE code_verifier,
and keep both for the callback (in a cookie or the session). Then redirect the
browser to Hydra's authorization endpoint:
GET {issuer}/oauth2/auth
?client_id=flow-sample-web
&response_type=code
&scope=openid
&audience=https://api.flow.internal
&redirect_uri=http://127.0.0.1:8765/callback
&state=<random>
&code_challenge=<BASE64URL(SHA-256(code_verifier))>
&code_challenge_method=S256Every parameter is required:
- PKCE with
S256. Hydra refuses a public client without it, and refuses theplainmethod. audience, set to one the client is registered for. Without it, consent is refused, because a token with no audience is one no service accepts.
The person sees the sign-in app's screens: password or Google, and registration
if they are new. There is no consent screen. The sign-in app accepts consent on
the server for first-party clients. It remembers nothing, so prompt=none does
not work.
Exchange the code for tokens
Hydra redirects to redirect_uri with code and state. Check that state
matches the one you stored, then exchange the code:
curl -X POST {issuer}/oauth2/token \
-d grant_type=authorization_code \
-d client_id=flow-sample-web \
-d code=<code> \
-d redirect_uri=http://127.0.0.1:8765/callback \
-d code_verifier=<code_verifier>The response holds:
access_token: a JWT that expires after one hour. Itssubis the person's FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request. and itsaudis the audience you asked for.id_token: proves who signed in to your app. Keep it for sign-out. Never send it to a service; services refuse it.
There is no refresh token. When the access token expires, send the person through the authorization step again. While their sign-in session is still active they are not asked to sign in again.
Call services with the access token
Send the access token on every request:
GET /v1/... HTTP/1.1
Authorization: Bearer <access_token>A 401 means the token is missing, expired or not for this service: get a new token. A 403 means the person is signed in but has no grant that allows the action. Grants are added in the user admin app. The token itself carries no roles, so there is nothing in it for the app to read or check.
Keep tokens on the server where you can. A Next.js app can hold them in an
encrypted, HttpOnly session cookie and call services from server code, so the
token never reaches browser JavaScript. The sample web app does this; see
frontend/sample-app/src/server/session.ts and
frontend/sample-app/src/server/sampleService.ts.
The same token works at every service, so whoever holds it can act as the person anywhere until it expires. Never log it, never put it in a URL, and send it only to the platform's own services.
Show who is signed in
The access token names the person only by their FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request.. It carries no email and no name, and neither does the identity service, which stores no personal data. Kratos holds them. Ask the profile service for them, with the same token:
GET /v1/me/profile HTTP/1.1
Authorization: Bearer <access_token>{
"fid": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
"email": "[email protected]",
"email_verified": true,
"name": {"first": "Pat", "last": "Lee"}
}The body is inside the platform's usual payload.data envelope. The profile
service reads the person's own Kratos identity each time it is asked, so a
changed name shows at once. It always answers for the token's sub: it takes
no parameters, and there is no way to ask for someone else. A name part that is
not set is an empty string. Only a person has a profile, so a machine client's
token gets a 403.
On dev it is at https://authn.dev.apps-and-services.com/v1/me/profile, a
route on the sign-in host; locally the profile service is at
http://127.0.0.1:8139. The sample web app calls it in
frontend/sample-app/src/server/profileService.ts and
shows "Signed in as" with the email. If the call fails, show the rest of the
page without it. The rules are in services/profile/README.md.
Decide what to show
Ask the identity service what the person may do, with the same token:
GET /v1/authz/me HTTP/1.1
Authorization: Bearer <access_token>{
"fid": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
"kind": "person",
"can": [
{"scope_type": "building", "scope_id": "0193b000-0000-7000-8000-00000000a001",
"actions": ["sample::CreateWorkOrder", "sample::ListWorkOrders"]},
{"scope_type": "global", "scope_id": "*", "actions": ["sample::CreateWorkOrder"]}
]
}The body is inside the platform's usual payload.data envelope. Show a button
only when its action is listed at the right building or at global. The answer
is what the person's grants allow, before any rule about a particular record,
so a service can still refuse a call: always handle a 403. It names no roles,
and a new grant shows in it at once.
On dev it is at https://authn.dev.apps-and-services.com/v1/authz/me, a route
on the sign-in host; locally identity is at http://127.0.0.1:8134. The sample
web app calls it in frontend/sample-app/src/server/identityService.ts.
The rules are under "What may I do" in services/identity/README.md.
Sign the person out
Send the browser to Hydra's logout endpoint with the ID token and a registered
post_logout_redirect_uri:
GET {issuer}/oauth2/sessions/logout
?id_token_hint=<id_token>
&post_logout_redirect_uri=http://127.0.0.1:8765/logged-out
&state=<random>The sign-in app ends both the Kratos session and the Hydra session, then Hydra
sends the browser to post_logout_redirect_uri. Clear the app's own session
too. Without a valid id_token_hint, the person is asked to confirm instead of
being signed out at once. The details are in frontend/authn-app/README.md.
Mobile apps
A native app is also a public client and uses the same flow. It opens the
authorization URL in the system browser session (ASWebAuthenticationSession on
iOS), and its redirect URI is a custom scheme or universal link registered on the
client. No mobile client is registered yet.