-
Notifications
You must be signed in to change notification settings - Fork 10
fix(sso): redeem an SSO code for a user-management session #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ec80180
a6d5774
b9e1b87
58b04b3
da0d830
965f288
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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({ | ||
|
greptile-apps[bot] marked this conversation as resolved.
Comment on lines
+332
to
+336
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a first-time SSO profile makes a configured JWT template exceed the rendered-claims limit, this code deletes the authorization and inserts the user before template rendering returns 422. The user and Knowledge Base Used: Prompt To Fix With AIThis is a comment left during a code review.
Path: src/workos/routes/auth.ts
Line: 326-330
Comment:
**Template failure leaves provisioned user**
When a first-time SSO profile makes a configured JWT template exceed the rendered-claims limit, this code deletes the authorization and inserts the user before template rendering returns 422. The user and `user.created` event remain without a session, and retrying the consumed code returns `invalid_grant`.
**Knowledge Base Used:**
- [Authentication and session flows](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/authentication-and-session-flows.md)
- [Enterprise SSO and OAuth through the emulator](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/enterprise-sso-and-oauth.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accurate, and deliberate — declining. Two halves:
The full fix (deferring There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a solid argument on both counts. The parity with the other grant arms is the decisive part — singling out SSO for a deferred-insert pattern while The doc comment at 965f288 capturing the trade-off is the right call. No further changes needed here. |
||
| 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, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.