diff --git a/README.md b/README.md index 339e205..c2c1b63 100644 --- a/README.md +++ b/README.md @@ -536,6 +536,14 @@ curl -X POST http://localhost:4100/user_management/authenticate \ The resulting session records the method that was gated (`password`), not the verification step. Driving the grant with `user_id` instead of a pending token still works, and that session reports `unknown` — there is no primary method to recover. Fixtures that sign in with a password want `email_verified: true`, in a seed file or on `POST /user_management/users`. +### SSO logins produce a session + +A code from `GET /sso/authorize` redeems at `POST /user_management/authenticate` with `grant_type=authorization_code`, so an app that sends people straight to their IdP with `sso.getAuthorizationUrl` and finishes at AuthKit's callback gets a real session — one whose `auth_method` is `sso`, so authorization code that hides password management for federated users can be exercised. `POST /sso/token` still redeems the same code for a bare profile and access token, which is the standalone SSO product and creates no session; a code is spent by whichever endpoint gets it first. + +The session is scoped to the connection's organization. A profile with no user-management account yet gets one, verified — the IdP asserted the address — the way AuthKit provisions on a first SSO login. + +As with `/user_management/authorize`, `/sso/authorize` is public and signs in whoever `login_hint` names: there is no IdP here to prove an identity with, and inventing a profile for any address is what lets a test drive an SSO login at all. Both endpoints will therefore hand out a session for any account you ask them for. That is the emulator being a test double, not an authorization server — do not point anything at it that you would not also let sign in as your users. + ### Refresh tokens always rotate The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `{"error": "invalid_grant", "error_description": "Invalid refresh token."}`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production. diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index fb3797b..477caff 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -31,7 +31,7 @@ import { } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; -import type { WorkOSInvitation } from '../entities.js'; +import type { WorkOSInvitation, WorkOSSSOAuthorization, WorkOSUser } from '../entities.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; import { renderLoginPage, renderDeviceVerifyPage } from '../login-page.js'; @@ -257,7 +257,12 @@ export function authRoutes(ctx: RouteContext): void { /** Emit the spec's authentication.*_failed event for a credential failure, then throw. */ const failAuth: ( method: string, - info: { email?: string | null; userId?: string | null }, + info: { + email?: string | null; + userId?: string | null; + /** Required on every authentication.sso_* event, per the spec's event data. */ + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; + }, error: WorkOSApiError, ) => never = (method, info, error) => { emitAuthenticationEvent({ @@ -269,10 +274,83 @@ export function authRoutes(ctx: RouteContext): void { ipAddress: requestIp, userAgent: requestUserAgent, error: { code: error.code, message: error.message }, + sso: info.sso, }); throw error; }; + /** + * Redeem an /sso/authorize code into the user-management user it signs in, provisioning one + * when the federated profile has no account yet — AuthKit does the same on a first SSO login, + * and /sso/authorize mints a profile for any address it is handed, so refusing here would + * report a code the emulator had just issued as invalid. + * + * Provisioning deliberately lands before the shared template gate below: a JWT template that + * cannot render fails the request but keeps the user, the same way the gate already keeps the + * membership acceptInvitation persists. Both are real domain progress — the user is the exact + * record a successful retry would create — and the burned code matches what a template failure + * costs every other one-time grant. + */ + const redeemSsoAuthorization = (ssoAuth: WorkOSSSOAuthorization, code: string): WorkOSUser => { + const profile = ws.ssoProfiles.get(ssoAuth.profile_id); + + if (isExpired(ssoAuth.expires_at)) { + ws.ssoAuthorizations.delete(ssoAuth.id); + failAuth( + 'SSO', + { + email: profile?.email, + userId: findUserByEmail(ws, profile?.email ?? '')?.id ?? null, + sso: { + organization_id: ssoAuth.organization_id, + connection_id: ssoAuth.connection_id, + session_id: null, + }, + }, + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), + ); + } + + // The same emulator-state failure /sso/token names, for the same reason: an authorization + // pointing at a profile that no longer exists is not a request anyone can fix by sending + // something else, so it stays plain rather than OAuth-shaped. + if (!profile) throw new WorkOSApiError(500, 'Profile not found', 'server_error'); + + // The shared recipient check below runs only after the grant, and by then this helper has + // spent the one-time authorization and possibly provisioned an account — a mismatched + // invitation would fail the request yet leave a user behind with no session. The profile + // already names who is signing in, so ask before anything is consumed; a rejected caller + // keeps the code and retries without the invitation. + if (invitation && !emailsMatch(invitation.email, profile.email)) { + throw new WorkOSApiError( + 400, + 'The invitation was issued for a different email address', + 'invitation_cannot_be_used_for_email', + ); + } + + ws.ssoAuthorizations.delete(ssoAuth.id); + + const existing = findUserByEmail(ws, profile.email); + if (existing) return existing; + return ws.users.insert({ + object: 'user', + email: profile.email, + name: null, + first_name: profile.first_name, + last_name: profile.last_name, + // The IdP asserted the address, which is what verification proves. + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); + }; + /** * Initiate the MFA second factor. Records the primary method on a pending-auth token so * the eventual session reports it (not 'unknown'), creates a challenge for the factor, and @@ -371,6 +449,9 @@ export function authRoutes(ctx: RouteContext): void { // a leniency production doesn't permit — store null. In both cases the redemption request // is the only client identity the emulator ever has. let grantClientId: string | undefined; + // The connection an SSO sign-in came through, carried to the authentication.sso_succeeded + // event, whose spec payload requires an `sso` block. Null for every other grant. + let ssoContext: { organization_id: string | null; connection_id: string | null } | null = null; switch (grantType) { case 'authorization_code': { @@ -381,6 +462,23 @@ export function authRoutes(ctx: RouteContext): void { // as invalid_grant with the same description. const authCode = ws.authCodes.findOneBy('code', code); if (!authCode) { + // A code minted by /sso/authorize is redeemable here too. The two endpoints wrote to + // different stores, so an app that starts SSO with `sso.getAuthorizationUrl` — sending + // people straight to their IdP rather than through a hosted screen — and finishes at + // AuthKit's callback got invalid_grant for a code the emulator had just issued. + // /sso/token still redeems the same code for a bare profile; this is the path that + // produces a session, and the only one that records auth_method 'sso'. + const ssoAuth = ws.ssoAuthorizations.findOneBy('code', code); + if (ssoAuth) { + user = redeemSsoAuthorization(ssoAuth, code); + organizationId = ssoAuth.organization_id; + ssoContext = { organization_id: ssoAuth.organization_id, connection_id: ssoAuth.connection_id }; + // An SSO authorization records no client_id, so the redeeming request is the only + // client identity there is — the same fallback a client-less /authorize gets. + grantClientId = clientId; + authMethod = 'SSO'; + break; + } failAuth( 'OAuth', {}, @@ -988,6 +1086,9 @@ export function authRoutes(ctx: RouteContext): void { email: updatedUser.email, ipAddress: session.ip_address, userAgent: session.user_agent, + // Required on authentication.sso_* by the spec's event data. This is the only SSO path + // that reaches a session, so it is also the only one that can report a session_id. + sso: ssoContext ? { ...ssoContext, session_id: session.id } : undefined, }); } diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 6c0c306..f46caa3 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -107,6 +107,121 @@ describe('SSO routes', () => { expect(new Set(profiles.map((p) => p.connection_id))).toEqual(new Set([conn.id, conn2.id])); }); + describe('SSO code redeemed for a user-management session', () => { + /** Start SSO through `conn` and return the code the redirect carries. */ + async function ssoCode(connId: string, loginHint?: string) { + const res = await app.request( + `/sso/authorize?connection=${connId}&redirect_uri=http://localhost:3000/callback` + + (loginHint ? `&login_hint=${encodeURIComponent(loginHint)}` : ''), + ); + return new URL(res.headers.get('location')!).searchParams.get('code')!; + } + + const authenticate = (code: string, extra?: Record) => + app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_x', ...extra }), + }); + + it('signs the profile in, keying the session to sso', async () => { + const { org, conn } = await createOrgWithConnection(); + const ws = getWorkOSStore(store); + const user = ws.users.insert({ + object: 'user', + email: 'alice@sso.example.com', + name: null, + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); + + const res = await authenticate(await ssoCode(conn.id, 'alice@sso.example.com')); + + expect(res.status).toBe(200); + const body = await json(res); + expect(body.user.id).toBe(user.id); + expect(body.organization_id).toBe(org.id); + expect(body.authentication_method).toBe('SSO'); + const [session] = ws.sessions.findBy('user_id', user.id); + expect(session.auth_method).toBe('sso'); + + // The spec requires an `sso` block on authentication.sso_succeeded; this is the only SSO + // path that reaches a session, so it is the only one that can fill in session_id. + const [event] = ws.events.all().filter((e) => e.event === 'authentication.sso_succeeded'); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'succeeded', + user_id: user.id, + sso: { organization_id: org.id, connection_id: conn.id, session_id: session.id }, + }); + }); + + it('provisions a user the federated profile has no account for', async () => { + const { conn } = await createOrgWithConnection(); + + const body = await json(await authenticate(await ssoCode(conn.id, 'newcomer@sso.example.com'))); + + expect(body.user.email).toBe('newcomer@sso.example.com'); + // The IdP asserted the address, which is what verification proves. + expect(body.user.email_verified).toBe(true); + expect(getWorkOSStore(store).users.all()).toHaveLength(1); + }); + + it('spends the code once, and reports an expired one as invalid_grant', async () => { + const { org, conn } = await createOrgWithConnection(); + const ws = getWorkOSStore(store); + + const code = await ssoCode(conn.id, 'once@sso.example.com'); + expect((await authenticate(code)).status).toBe(200); + const replay = await authenticate(code); + expect(replay.status).toBe(400); + expect((await json(replay)).error).toBe('invalid_grant'); + + const expired = await ssoCode(conn.id, 'stale@sso.example.com'); + const stored = ws.ssoAuthorizations.findOneBy('code', expired)!; + ws.ssoAuthorizations.update(stored.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + + const res = await authenticate(expired); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + const [failed] = ws.events.all().filter((e) => e.event === 'authentication.sso_failed'); + expect(failed.data).toMatchObject({ + type: 'sso', + status: 'failed', + email: 'stale@sso.example.com', + sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, + }); + }); + + it('rejects a mismatched invitation before the code is spent or a user provisioned', async () => { + const { org, conn } = await createOrgWithConnection(); + const invitation = await json( + await req('/user_management/invitations', { + method: 'POST', + body: JSON.stringify({ email: 'recipient@sso.example.com', organization_id: org.id }), + }), + ); + + const code = await ssoCode(conn.id, 'interloper@sso.example.com'); + const res = await authenticate(code, { invitation_token: invitation.token }); + + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invitation_cannot_be_used_for_email'); + // Nothing was consumed: no account was provisioned for the interloper, and the same code + // still signs in once the invitation is dropped. + expect(getWorkOSStore(store).users.all()).toHaveLength(0); + expect((await authenticate(code)).status).toBe(200); + }); + }); + it('sso token exchange returns profile and access_token', async () => { const { conn } = await createOrgWithConnection();