Skip to main content

Resource: Auth / Session

Tag: Auth · Modules: authentication + session (+ signups moved in from user)

Sign-in/up, OAuth & magic-link flows, token/session lifecycle, org selection/switching. Returns the shared User DTO (defined in user.md).

Grounded: authentication/api/*, authentication/models/types.go, internal/domain/auth/types.go, session/api/*, pkg/token/session_access.go (cookie), shared/authresponse/multiorg.go.


Token delivery (LOCKED — the cross-cutting auth model)

Auth success responses return the user, never tokens, in the body. Tokens are NOT in the body.

  • The access token is set as an HttpOnly access_token cookie (pkg/token/session_access.go, SameSite=Lax) — this is the cookieAuth security scheme the rest of the API authenticates with.
  • The refresh token is stored server-side (DB session), never sent to the client.
  • So the internal AuthTokens struct is never serialized; login-ish ops respond with the User (or the AuthResult union where the flow can branch) + a Set-Cookie. The cookie is documented via the security scheme.

This applies to: finalize-oauth, finalize-magic-auth, select-organization, switch-organization, and sign-up.

OAuth client model (web = BFF/confidential, desktop = public)

  • Web is a Backend-for-Frontend (confidential client): the browser gets the code and POSTs it to the Go backend, which exchanges it with WorkOS using the API key and sets the cookie — the browser never sees tokens. This is the recommended SPA model (OAuth browser-apps BCP). PKCE is omitted on the web flow and that's spec-legal (RFC 9700: PKCE MUST for public clients, RECOMMENDED for confidential).
  • Desktop is a public client: it uses client-side PKCE (codeChallenge at start, codeVerifier at finalize) — MUST, per RFC 9700.
  • DECIDED — backend-managed PKCE for web. The web flow uses PKCE with the verifier/challenge generated and stored server-side (session keyed by state), never the browser (defense-in-depth — the code can leak via history/referrer/logs). So finalize-oauth's codeVerifier is desktop-only (public-client, client-side PKCE); the web client sends no PKCE fields — the backend handles it.

Refresh is transparent (no client endpoint)

There is no client-facing refresh op. The refresh token lives server-side (session); on access-token expiry the auth middleware refreshes transparently (WorkOS AuthenticateWithRefreshToken) and re-sets the cookie. The legacy refresh-auth-token + RefreshTokenRequest{refreshToken} (client-sent token) was a pre-cookie leftover — removed (anti-pattern in a cookie/BFF model).


Flows

  1. Discoverysign-in (email) → returns which method to use (sso | magicAuth) + any redirect/PKCE data.
  2. Magic linkmagic/resend (send code) → magic/finalize (email+code) → logged in.
  3. SSO/OAuthoauth/{provider}/start → returns the provider redirect → (client completes at provider) → oauth/finalize (code [+ PKCE verifier] [+ invite token]) → logged in.
  4. Multi-org branch — if the user belongs to several orgs, finalize returns the orgSelectionRequired AuthResult variant (pendingAuthenticationToken + org list) instead of authenticated; the client calls organization/select to complete login.
  5. Sessionsign-out, organization/switch (post-login). Refresh is transparent (no client op, see above).

Operations

OperationIDMethod + PathSecurityNotes
sign-inPOST /auth/sign-inpublicdiscovery (email → method). Merges sign-in + web-sign-in (platform via header)
start-oauthPOST /auth/oauth/{provider}/startpublicprovider = google|microsoft (path param). Merges continue-with-google + continue-with-microsoft
resend-magic-authPOST /auth/magic/resendpublicsend the OTP
finalize-magic-authPOST /auth/magic/finalizepublicemail+code → {user} | org-selection
finalize-oauthPOST /auth/oauth/finalizepubliccode(+PKCE)(+invite) → {user} | org-selection
select-organizationPOST /auth/organization/selectpublicpendingAuthToken + orgId → {user} (login-time)
sign-upPOST /auth/sign-uppublicbody {email, name?, inviteToken?, inviteType?}. Merges plain/admin/member signup
switch-organizationPOST /auth/organization/switchcookiepost-login org change (was /organizations/switch-organization)
sign-outPOST /auth/sign-outcookieclears cookie + server session
dev-sign-inPOST /auth/dev-sign-inpublicdev/local only — gate by env, exclude from prod spec

generate-pilot-invite (internal/admin tooling) stays a plain gin/internal route — not in the typed contract.

Consolidation rationale (parameter, not path)

Three legacy splits were merged because they differ only by a parameter — confirmed by the code and by every vendor (WorkOS routes all auth through one /user_management/authenticate keyed by grant_type):

  • platform (sign-in vs web-sign-in) → one endpoint; platform is a header. WebSignIn was the same email→method op, just not returning the redirect data.
  • provider (continue-with-google/-microsoft) → one route with {provider} as a path param (/auth/oauth/{provider}/start), not the body — the provider is a routing selector (distinct IdP / redirect / config), so it belongs in the URL (per-provider metrics/limits/logs; OAuth-ecosystem norm: Passport/NextAuth/Devise all path-encode it). The handler already parameterized it (Handle(c, "GoogleOAuth"|"MicrosoftOAuth")).
  • invite type (plain/admin/member signup) → {inviteToken?, inviteType?} body.

What stays separate: oauth/finalize (code+PKCE) vs magic/finalize (email+OTP) take different credentials, so distinct typed request bodies beat one polymorphic /authenticate{grant_type} for codegen. (They could collapse into a single /auth/authenticate with a grantType discriminator à la WorkOS if fewer endpoints is preferred — a deliberate alternative, not the default.)


DTOs

Response shape convention: single-object responses return the object/fields directly (no data wrapper); only lists use {data, page} (C4); a wrapper is used only for a genuine multi-shape response (the AuthResult union below). The old authData/user envelopes are dropped.

AuthResult — shared auth-completion response (discriminated union)

// One of (the authenticated branch also sends Set-Cookie):
{ "status": "authenticated", "user": { /* User */ } }
{ "status": "orgSelectionRequired",
"pendingAuthenticationToken": "string", // transient, single-use, short-TTL (OAuth-code-class)
"organizations": [ { "id": "string", "name": "string" } ] } // pick one → select-organization

pendingAuthenticationToken is a transient WorkOS credential the client echoes back to select-organization (same class as an OAuth code). Hold it in memory only (never persist/log); HTTPS-only; single-use. It is not on the User DTO (see user.md).

Discovery — sign-in (merges sign-in + web-sign-in)

// Request
{ "email": "string" } // valid email; platform (web|desktop) via header if behavior differs
// Response — AuthData object, returned directly (no wrapper)
{ "authMethod": "sso | magicAuth", "redirectUrl?": "string", "state?": "string", "challenge?": "string" } // C8 — optional (sso only)

OAuth start — start-oauth (merges google + microsoft)

POST /auth/oauth/{provider}/start{provider} is a path param (google | microsoft), a routing selector.

// Request body — web sends no PKCE (backend-managed); desktop (public client) sends codeChallenge
{ "state?": "string", "codeChallenge?": "string" } // C8 — both optional
// Response — AuthData, returned directly
{ "authMethod": "sso", "redirectUrl": "string", "state": "string", "challenge": "string" }

Finalize — finalize-magic-auth / finalize-oauth

// finalize-magic-auth Request
{ "email": "string", "code": "string" } // code = 6-digit OTP
// finalize-oauth Request
{ "code": "string", "codeVerifier?": "string", "inviteToken?": "string", "inviteType?": "admin | member" } // C8 — optional fields marked ?
// codeVerifier = DESKTOP only (public-client PKCE); web's PKCE is backend-managed (client sends none).
// Response — AuthResult (+ Set-Cookie on the authenticated branch)

select-organization (completes a multi-org login)

// Request
{ "pendingAuthenticationToken": "string", "organizationId": "string" }
// Response — User directly (+ Set-Cookie); always authenticated by this point
{ /* User */ }

Sign-up — sign-up (merges plain / admin / member)

// Request — invite type distinguishes the registration kind (was 3 endpoints)
{
"email": "string",
"name?": "string", // C8 — optional
"inviteToken?": "string", // present for invited registrations (WorkOS-native: invite rides the auth call)
"inviteType?": "admin | member" // disambiguates pilot-link vs org-invite namespaces
}
// Cleanup: ideally make the token self-describing so the backend derives the type — the client shouldn't
// declare `inviteType` (it can mismatch). Reconcile with the standalone accept-invitation (invitations.md):
// invite-on-auth = register-and-join; accept-invitation = already-authenticated user joins another org.
// Response — AuthResult (+ Set-Cookie)

Session — switch-organization / sign-out

// switch-organization Request
{ "organizationId": "string" } // WorkOS org id; → User directly + new Set-Cookie
// sign-out: empty body → 204, clears cookie + server session
// (refresh: no client op — transparent middleware, see "Refresh is transparent" above)

preview-organization (an unauthenticated join-page org lookup) was considered here but is deferred — not in the contract (no consumer today; the join route ignores the org slug). It will be designed if/when a branded join page or the guest-access feature needs it — see organizations.md "Deferred".


Public vs protected

  • Public ops declare no Security → the API-wide auth middleware self-skips them (migration plan §4.1). This covers discovery, oauth/magic start+finalize, select-organization, signups, and dev-sign-in.
  • Cookie-protected: sign-out, switch-organization. (Refresh is transparent middleware, not an op.)

Module / backend notes

  • authentication (flows) + session (switch/sign-out + transparent refresh middleware) merge under the Auth tag; signups move here from the user feature (they're registration ops).
  • switch-organization relocates from /organizations/* (it's a session op reissuing the cookie).
  • Token plumbing already moves to Huma middleware in the migration plan (cookie read off huma.Context).

Verify at implementation

  • Backend-managed PKCE for web — implement server-side verifier/challenge (session keyed by state) as defense-in-depth; the legacy client refresh/body-token shapes are removed (refresh is transparent middleware).
  • Self-describing invite token — fold inviteType into the token so the client doesn't declare it.
  • Auth path renames vs any external config (WorkOS redirect URIs target FE pages, but confirm) and the FE RequestActionEndpoints map.
  • dev-sign-in must be env-gated and excluded from the published prod spec.
  • Exact signup response on the multi-org branch.