OAuth
Login/signup with Google, GitHub, Microsoft, Discord, GitLab, or Apple. The flow involves a browser redirect, so most of it doesn't fit a single curl command.
Six providers: google, github, microsoft, discord, gitlab, apple. The router is provider-agnostic via a {provider} path parameter — every provider but Apple is a switch-statement case plus its own client ID/secret env vars, not a routing difference.
Routes
| Method | Path | Auth | Result |
|---|---|---|---|
GET | /oauth/{provider} | public | 302 to the provider's consent screen |
GET | /oauth/{provider}/callback | public | 200 tokens, or the paused second-factor shape |
GET | /oauth/{provider}/link | requires auth | 302 to the provider |
GET | /oauth/{provider}/link/callback | public | 200 {"data":{"status":"linked","provider":"..."}} |
A provider with no credentials configured on the server answers 404 oauth_provider_not_configured — this is a normal runtime state, not a startup failure, so a deployment can enable providers incrementally.
# not something you curl directly — a browser redirect:
open https://api.example.com/v1/oauth/googleAn OAuth login is still a login: if the account also has a second factor enrolled, the callback returns the same paused shape /login would — see Authentication.
Never auto-link
When a provider returns an email that already belongs to an existing password-based account, the answer is 409 oauth_email_conflict, with a message naming the provider — this is deliberately not resolved silently and is not folded into a generic login failure, since auto-linking on email match alone is treated as an account-takeover vector.
Resolution:
- The user logs in with their existing password via
/v1/login. - With a valid access token,
GET /v1/oauth/{provider}/linkstarts linking, completed at/v1/oauth/{provider}/link/callback.
Why link/callback is public despite linking requiring auth
A browser redirect coming back from a provider carries no Authorization header at all — there's no way for it to. Instead, the linking user's identity travels in a short-lived, HMAC-signed cookie that link sets before redirecting, signed with JWT_SECRET and verified on return rather than trusted blindly. A tampered cookie cannot be used to link a different account. The cookie lives for 600 seconds — long enough for a real provider round trip, short enough that a stale one is useless.
Errors
oauth_provider_not_configured (404), oauth_state_mismatch (400), oauth_email_not_available (400), oauth_identity_verification_failed (400), oauth_link_session_missing (400), oauth_link_not_configured (500), oauth_email_conflict (409), oauth_identity_already_linked (409).
Apple is the one provider that isn't another switch case
The other five are the same OAuth2 authorization-code flow with different URLs. Apple genuinely isn't:
- Apple's client "secret" isn't a static string — it's an ES256 JWT this server signs itself, per exchange, from a private key. That means Apple needs all four of
APPLE_CLIENT_ID(the Services ID, e.g.com.example.web— not the app's bundle ID),APPLE_TEAM_ID,APPLE_KEY_ID, andAPPLE_PRIVATE_KEY(the contents of the.p8key file), where every other provider needs only a client ID and a client secret. - A PEM-format private key can't sit on one line of a
.envfile, so literal\nsequences inAPPLE_PRIVATE_KEYare converted to real newlines — but only when the value actually looks like it needs that, so a key supplied through a real secret manager (which already has real newlines) is left untouched. - The user's email comes from the token response's
id_token, a signed JWT, verified against Apple's own published JWKS before anything inside it is trusted — not from a separate userinfo call the way the other five work. - Apple sends the user's name only on the first authorization ever, never again after that; this deployment doesn't attempt to capture it at all, only the email from the verified
id_token.
None of this is visible from a client's perspective — routes and response shapes are identical to every other provider. It only means an Apple login takes a different path once it reaches the server.