Authentication Flow (Internal)
This page documents the detailed internal mechanics of authentication in Aero2, covering both the OIDC Provider and Relying Party flows.
OIDC Provider Flow
When Aero2 acts as an OIDC Provider, it issues tokens to registered OAuth clients on behalf of authenticated users.
Client App Aero2 Worker D1 Database
| | |
| GET /oauth2/authorize | |
| ?client_id=... | |
| &redirect_uri=... | |
| &response_type=code | |
| &scope=openid profile | |
| &code_challenge=... | |
| &code_challenge_method=S256| |
| &state=... | |
| &nonce=... | |
|---------------------------->| |
| | Validate params |
| | - client_id exists? |
| | - redirect_uri allowed? |
| | - PKCE S256 required |
| | - scopes valid? |
| |-------------------------------->|
| | Check active session |
| |<--------------------------------|
| | |
| 302 -> /login | No session: redirect to login |
|<----------------------------| |
| | |
| (user authenticates) | |
| | |
| | Generate auth code |
| | Store code + PKCE + nonce |
| |-------------------------------->|
| | |
| 302 -> redirect_uri | |
| ?code=AUTH_CODE | |
| &state=... | |
|<----------------------------| |
| | |
| POST /oauth2/token | |
| grant_type=authorization_code |
| code=AUTH_CODE | |
| code_verifier=... | |
| client_id=... | |
| client_secret=... | |
|---------------------------->| |
| | Verify client credentials |
| | Verify PKCE (S256) |
| | Delete code (atomic) |
| |-------------------------------->|
| | |
| | Sign JWT with Durable Object |
| | (JWKS key management) |
| | |
| 200 { | |
| access_token: "...", | |
| id_token: "...", | |
| refresh_token: "...", | |
| token_type: "Bearer", | |
| expires_in: 3600 | |
| } | |
|<----------------------------| |Key Implementation Details
- File:
src/backend/op.ts - Auth codes are consumed atomically using
DELETE ... RETURNINGto prevent replay - PKCE with S256 is mandatory; plain method is rejected
- Tokens are signed using the JWKS Durable Object (
src/backend/jwks.ts) - Access tokens include
token_use: "access", ID tokens includetoken_use: "id" - Refresh tokens are HMAC-hashed before storage in D1
- The
nonceclaim is included in ID tokens when provided in the authorize request - Users with
users.membership_status = 'pending'(invite-only signups without a valid invite, or similar) are not issued an authorization code at/oauth2/authorize; they are redirected to the app login witherror=membership_pending. - The token endpoint (
POST /oauth2/tokenwithauthorization_code) rejects pending users withinvalid_grant(403) so codes cannot be exchanged if state changed between steps.
Relying Party Flow
When Aero2 acts as a Relying Party, it delegates authentication to an external identity provider (GitHub, Google, or a custom OIDC/OAuth2 provider).
User Browser Aero2 Worker External IdP D1
| | | |
| GET /rp/authorize/github| | |
| ?invite=<token> | | |
|---------------------------->| | |
| | Generate state + PKCE | |
| | Store state + invite_token| |
| |----------------------------------------------->|
| | Set state cookie (HttpOnly, SameSite=Lax) |
| | | |
| 302 -> github.com/ | | |
| login/oauth/authorize | | |
| ?client_id=... | | |
| &state=... | | |
| &scope=user:email | | |
|<----------------------------| | |
| | | |
| (user authenticates | | |
| at GitHub) | | |
| | | |
| GET /rp/callback/github | | |
| ?code=GITHUB_CODE | | |
| &state=... | | |
|---------------------------->| | |
| | Verify state | |
| | - Match state param | |
| | to state cookie | |
| | - Atomic consume from D1 | |
| |----------------------------------------------->|
| | | |
| | Exchange code for tokens | |
| |--------------------------->| |
| | {access_token, id_token} | |
| |<---------------------------| |
| | | |
| | Fetch user info | |
| |--------------------------->| |
| | {email, name, picture} | |
| |<---------------------------| |
| | | |
| | Create or link user | |
| | - Find by email + app_id | |
| | - Create if not exists | |
| | - Link identity | |
| |----------------------------------------------->|
| | | |
| | Create session | |
| | - Generate session JWT | |
| | - Store in user_sessions | |
| |----------------------------------------------->|
| | | |
| 302 -> / | | |
| Set-Cookie: session=JWT | | |
| (HttpOnly, Secure, | | |
| SameSite=Strict) | | |
|<----------------------------| | |Key Implementation Details
- File:
src/backend/rp.ts - State is stored in both D1 and an HttpOnly cookie for double-bind CSRF protection
- State is consumed atomically:
DELETE FROM oauth_state WHERE state = ? AND idp_name = ? AND expires_at > datetime('now') RETURNING redirect_uri, invite_token, ... - Optional query parameter
inviteon/rp/authorizeis stored inoauth_state.invite_tokenand used when creating or activating users so OAuth sign-in respects the same invite-only / restricted-domain policy as email-code signup. - PKCE code verifier is generated for providers that support it
- User creation/linking is done in a single flow: look up by email, create if new, add identity link
- External IdP tokens (access/refresh) are encrypted with AES-256-GCM before storage
Session Creation
The relying-party callback flow and successful email-code verification (when membership is active after verify) follow the same session steps:
- Generate a session JWT signed by the JWKS Durable Object
- Session JWT includes:
sub(user ID),iat,exp,token_use: "session",auth_time, andmembership_status(activeorpending) - Store session metadata in
user_sessionstable (token hash, IP, user agent, expiry) - Set the session JWT as an HttpOnly, Secure, SameSite=Strict cookie
Email code (POST /api/auth/verify-code, src/backend/email-code.ts): If the user is still pending after verification (and no invitation promotes them to active), the handler returns 200 with membership_pending: true and a message, and does not mint a session JWT, write user_sessions, or set the access_token cookie—matching the OAuth authorize gate for pending users.
Token Verification
When a protected endpoint receives a request:
- Extract token from
Authorization: Bearer <token>header or session cookie - Fetch the JWKS from the Durable Object (cached with ETag)
- Verify the RS256 signature against the JWKS
- Validate standard claims:
iss(issuer),aud(audience),exp(not expired) - Validate
token_useclaim matches expected type (session, access, or id) - Look up session in D1 to verify it has not been revoked
- Look up user to verify account is not disabled
- If
membership_statusispending, only a small allowlist of routes is permitted (e.g.GET /api/users/me); other API calls return 403 withcode: "membership_pending". - Attach user and session objects to the Hono request context
Operators can approve pending users via POST /api/applications/users/:userId/approve (dashboard), which sets membership_status to active and assigns the default app role.
Key Files
| File | Responsibility |
|---|---|
src/backend/op.ts | OIDC Provider: authorize, token, userinfo, revoke endpoints |
src/backend/rp.ts | Relying Party: authorize redirect, callback, user creation |
src/backend/middleware/auth.ts | Token verification, session validation, RBAC checks |
src/backend/utils/token.ts | JWT signing (via DO), verification, claims building |
src/backend/utils/crypto.ts | HMAC hashing, AES-256-GCM encryption, PBKDF2 |
src/backend/jwks.ts | Durable Object: key generation, rotation, JWKS endpoint |