fix(sso): redeem an SSO code for a user-management session - #70
Conversation
/sso/authorize wrote to ssoAuthorizations while the authorization_code grant read authCodes, so a code the emulator had just issued came back invalid_grant at /user_management/authenticate. An app that starts SSO with sso.getAuthorizationUrl — sending people straight to their IdP rather than through a hosted screen — had no way to finish at AuthKit's callback. The grant now falls back to ssoAuthorizations, which is also the first path to record a session with auth_method 'sso': AUTH_METHOD_SESSION_VALUES mapped it, but no grant ever assigned it, so nothing gating on a federated session could be exercised. /sso/token still redeems the same code for a bare profile; a code is spent by whichever endpoint gets it first. A profile with no user-management account is provisioned one, verified, the way AuthKit does on a first SSO login — /sso/authorize mints a profile for any address it is handed, so refusing would report a freshly issued code as invalid. The succeeded and failed events carry the `sso` block the spec's event data requires, with the session_id no other SSO path has. Fixes #67
Greptile SummaryThe PR allows SSO authorization codes to be redeemed through user-management authentication, producing an organization-scoped session with the SSO authentication method.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
|
The shared recipient check runs only after the grant, and by then the SSO helper had already spent the one-time authorization and possibly provisioned an account — so a request failing with invitation_cannot_be_used_for_email burned a code the caller could not get back and left a freshly created user (and its user.created event) behind with no session. The other grants burn their credential the same way, but only this path could also invent an account on the way down. The profile already names who is signing in before anything is consumed, so ask first: a rejected caller keeps the code and retries without the invitation. Raised by review on the PR.
…nt-session # Conflicts: # README.md
| ws.ssoAuthorizations.delete(ssoAuth.id); | ||
|
|
||
| const existing = findUserByEmail(ws, profile.email); | ||
| if (existing) return existing; | ||
| return ws.users.insert({ |
There was a problem hiding this 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:
Prompt To Fix With AI
This 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.There was a problem hiding this comment.
Accurate, and deliberate — declining. Two halves:
- The burned code isn't SSO-specific. Under a template render failure the regular
authorization_codeand magic-auth arms burn their one-time credential identically, and a broken JWT template is an environment-wide config error that fails every grant until fixed. A fresh SSO code is one redirect away. - The provisioned user is the same record a successful retry would create. The template gate already tolerates exactly this class of persisted progress: it must run after
acceptInvitation, so an accepted invitation and its membership survive a render failure too, by design. JIT provisioning is real domain progress, not orphaned state, anduser.createdreports a user that genuinely exists.
The full fix (deferring users.insert past the gate via a candidate user) would still leave the orphan in the SSO + invitation + broken-template intersection and adds a commit hook to the shared path for a misconfiguration edge — not worth it in a test double. 965f288 records the trade-off in the helper's doc comment.
There was a problem hiding this comment.
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 authorization_code and magic-auth leave the same artifact under the same failure would be an inconsistency, not a fix. And the template gate's existing contract (invitations + memberships survive) already encodes the principle that progress past business-logic checkpoints is real state. Provisioning a user is in the same category.
The doc comment at 965f288 capturing the trade-off is the right call. No further changes needed here.
A JWT template that cannot render fails the request after the SSO helper has spent the code and provisioned a first-time user. That is deliberate, not an oversight: the template gate already keeps the membership acceptInvitation persists, the provisioned user is the exact record a successful retry would create, and the burned code is what a template failure costs every other one-time grant. Raised by review on the PR; recording the reasoning where the next reader will look.
Fixes #67.
/sso/authorizeinserted intossoAuthorizations; theauthorization_codegrant readauthCodes. So a code the emulator had just minted came backinvalid_grant, and an app that starts SSO withsso.getAuthorizationUrl— deliberately skipping the hosted screen and sending people straight to their IdP — had no way to finish at AuthKit's callback.You asked which way to do it, so: the grant falls back to
ssoAuthorizations, rather than teaching/user_management/authorizeto speakconnection/organization. That is the smaller change, it keeps the IdP-first redirect the issue is about, and it is the only one that fixes the related half — no grant ever assignedauthMethod = 'SSO', soAUTH_METHOD_SESSION_VALUES'SSO: 'sso'entry was unreachable and nothing gating on a federated session (hiding password management, say) could be exercised. Now it is the session's recorded method.POST /sso/tokenis untouched and still redeems the same code for a bare profile and access token — the standalone SSO product, which creates no session. A code is spent by whichever endpoint gets it first.Two decisions worth flagging:
email_verified: true(the IdP asserted the address), the way AuthKit provisions on a first SSO login./sso/authorizemints a profile for any address it is handed, so the alternative was reporting a freshly issued code as invalid.org_idis on the token, but a user with no membership gets noroleclaim. Say the word and I'll add it.The succeeded and failed events carry the
ssoblock the spec's event data requires (EVENT_DATA_REQUIREMENTSlists it as required on everyauthentication.sso_*), with thesession_idno other SSO path can fill in.Verification
bun test(854 pass, 3 new),bun run typecheck,bun run lint,bun run fmt:check.