Skip to content

Magic Auth can't produce an org-scoped session #28

Description

@peppedg

Hey @gjtorikian. We're using go-sdk-v6 in production and have been running the emulator in our local dev stack (Go backend, WorkOS for auth) and we're experiencing some differences from production, I think one is a genuine gap, plus a second one that looks intentional but caught us out badly enough to be worth a line in the README. Neither is blocking us, and being able to run our whole stack offline has been a big win, but those discrepancies are worth flagging.

Magic Auth can't produce an org-scoped session

Our users belong to exactly one org each. Against production, authenticating with a Magic Auth code returns organization_id on the response and puts org_id, role and roles into the access token. Against the emulator we get organization_id: null and a token carrying none of them.

Same user shape, same grant. Production:

{
  "sub": "user_01...",
  "sid": "session_01...",
  "org_id": "org_01...",
  "role": "member",
  "roles": ["member"],
  "permissions": [],
  "iss": "https://api.workos.com/user_management/client_01..."
}

Emulator, for a user with one active membership:

{
  "sub": "user_dev_admin",
  "sid": "session_01...",
  "iss": "http://localhost:4100"
}

In src/workos/routes/auth.ts, organizationId is declared null at line 227 and each grant branch assigns it before the token is signed — authorization_code at 276, refresh_token at 384, MFA at 439, organization-selection at 470. The Magic Auth branch (310–332) is the one that doesn't: it looks up the code, resolves the user, deletes the code and sets authMethod, then breaks without ever assigning organizationId. It doesn't read body.organization_id either — only the refresh branch does, at 384.

That then cascades. Role and permission resolution is gated on line 531 (if (organizationId)), so it's skipped, and line 553 signs org_id: organizationId ?? undefined. So a single missing assignment costs the token its org_id, role and permissions together.

The part that made me file this rather than work around it: there's no other route to an org-scoped session on this grant. The organization-selection grant is implemented at 447, but it needs a pending_authentication_token, and organization_selection_required is never emitted anywhere in the file — the only pending tokens come from the MFA challenge path. So the emulator can neither auto-select the org nor ask the client to select one. For a user with organizations, a Magic Auth sign-in has exactly one possible outcome: success with organizationId = null.

Production has no such state — it either resolves the org or returns organization_selection_required with the list. Our client handles both of those; it has no branch for the third, because there's nothing to branch on. The response is a 200 that looks fine apart from the missing claim.

For what it's worth, the Go SDK documents the production behaviour on AuthenticateResponse.OrganizationID, with the comment unchanged from v6 through v10:

If the user is a member of multiple organizations, this is the organization the user selected as part of the authentication flow.
If the user is a member of only one organization, this is that organization.
If the user is not a member of any organizations, this is null.

And it isn't something the caller can supply: AuthenticateWithMagicAuthOpts has ClientID, Code, Email plus optional LinkAuthorizationCode/IPAddress/UserAgent, and no organization field. Resolving it is the server's job on this grant.

The practical effect for us is that anything authorizing on org_id rejects every emulator-issued token, so the emulator can't stand in for WorkOS when testing an org-scoped API.

If it helps, the fix looks like it could sit right in that branch: when the user has exactly one active membership, select it. The membership lookup at 532–534 already does the retrieval, it just runs too late to help this path. Emitting organization_selection_required for multi-org users would close the other half, though that's clearly a bigger change.

One small thing alongside it: the token signed at 550–557 includes role and permissions but not the roles array production also returns. That doesn't matter today since org_id is absent anyway, but it would surface as soon as the above is fixed.

Refresh-token rotation — intentional, but worth documenting

The emulator issues a new refresh token on every refresh and deletes the presented one, so replaying it gives invalid_grant. Production, instead, returns the same refresh token every time and the original stays valid. We checked both on the same day against the same client.

I can see from the comment at 386–389 that this is deliberate, so I'm not reporting it as a bug, yet as a discrepancy:

// Rotate within the existing session: capture it for reuse, delete the old token,
// and issue a new one below — no new session, no authentication event.
refreshSessionId = refreshToken.session_id;
ws.refreshTokens.delete(refreshToken.id);

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions