OAuth
Provider login, callback, link, unlink, and connected accounts.
OAuth
Mounted when AuthenticationConfig.OAuth is true with at least one configured provider. Enabling OAuth without a provider fails startup; registration OAuth independently permits account creation. Auth is either Public or Session as marked. Provider tokens are never exposed: the connected-accounts route returns profile fields only.
Initiate login
GET /auth/oauth/{provider}
Starts a login. Returns the provider authorization URL as JSON and sets a short-lived, host-only HttpOnly state cookie. The frontend must use the same browser and cookie jar for the callback. Auth: Public.
Path params:
| Param | Type | Required | Notes |
|---|---|---|---|
provider | string | Required | A registered provider name (e.g. github) |
Response 200 OK:
{ "url": "https://provider.example/authorize?..." }Errors: provider_not_found (unknown provider name).
Callback
GET /auth/oauth/{provider}/callback, POST /auth/oauth/{provider}/callback
Provider redirect target (redirect URI ends here). GET reads code and state from the query without parsing a body. POST accepts application/x-www-form-urlencoded with a maximum 64 KiB body; body values take precedence over query values. Multipart uploads, other content types, malformed forms and oversized bodies redirect with invalid_request before consuming state. The callback requires the matching browser state cookie and clears it. A provider-link callback also requires the same live session that started the link. No JSON responses: every outcome is a redirect or an HTML page. Auth: Public, with the extra session requirement for links.
The state cookie uses SameSite=None; Secure when secure cookies are enabled so cross-site form POST callbacks can return it. When secure cookies are disabled for local HTTP development, it uses SameSite=Lax, which supports top-level GET callbacks; use HTTPS to test a cross-site POST callback.
Path params:
| Param | Type | Required | Notes |
|---|---|---|---|
provider | string | Required | A registered provider name |
Outcomes:
- Missing
code/state:302to{BaseURL}/auth/callback?error=invalid_request&provider={provider}. - Failure:
302to{BaseURL}/auth/callback?error={code}&provider={provider}, where{code}is theerrorcode (e.g.state_used,state_expired). - Link flow (caller already had a session):
302to{BaseURL}/auth/callback?step=linked. No new session is issued. - Verification required:
302to{BaseURL}/auth/callback?step=verify_email&provider={provider}. - 2FA required by global, per-account or admin policy:
302to{BaseURL}/auth/callbackwithstep=two_factor,provider,challengeId, andexpiresAtquery parameters, plus an HttpOnly challenge binding cookie. No session is issued. Submit the email code and challenge ID toPOST /auth/2fa/verifyusing the same browser cookie jar. The binding credential never appears in the redirect URL. - Invitation email proof required:
302to{BaseURL}/auth/callback?step=verify_invite_email&challengeId=..., plus a host-only HttpOnly invitation-binding cookie. Complete the code throughPOST /auth/oauth/invite/verify. - Normal login:
200HTML page that sets both cookies viaSet-Cookieand redirects client-side withwindow.location.replace("{BaseURL}/auth/callback?step=authenticated"). Cookies go in headers (not a bare302) becauseSet-Cookieon cross-origin redirects is unreliable in some browsers.
Link provider
POST /auth/oauth/{provider}/link
Starts linking a provider to the logged-in account. Returns the authorization URL as JSON and sets the browser state cookie. The callback must carry this cookie and the same live session. Auth: Session.
After provider exchange, finalization locks the user and initiating session and rechecks the exact session token, account eligibility and claimed state's current existence, bindings and expiry in the transaction that saves the link. A reset, email replacement, revocation or token rotation that wins before finalization prevents the link. The provider request runs outside those database locks.
Path params:
| Param | Type | Required | Notes |
|---|---|---|---|
provider | string | Required | A registered provider name |
Response 200 OK:
{ "url": "https://provider.example/authorize?..." }Errors: provider_not_found.
Unlink provider
POST /auth/oauth/{provider}/unlink
Removes the provider link. Blocked when it is the account's last usable login method. A disabled password method or an unavailable provider does not count as an alternative. Auth: Session. No body.
Path params:
| Param | Type | Required | Notes |
|---|---|---|---|
provider | string | Required | A registered provider name |
Response 200 OK:
{ "message": "Provider unlinked" }Errors: provider_not_found, cannot_unlink_last_provider (last login method; set a password first).
List connected providers
GET /auth/oauth/providers
Lists the caller's connected providers. Profile fields only. Auth: Session.
Response 200 OK:
{
"providers": [
{
"provider": "github",
"email": "user@example.com",
"name": "Ada",
"avatarUrl": "https://...",
"createdAt": "2025-01-01T00:00:00Z"
}
]
}Start invited signup
POST /auth/oauth/{provider}/invite
Starts provider signup from an account invitation while public signup can remain
closed. Requires authentication OAuth, registration OAuth, and
InviteSignup. Auth: Public, with mandatory CSRF and origin checks.
{ "inviteToken": "raw-token-from-the-invitation-link" }Returns 200 {"url":"https://provider.example/authorize?..."} and the normal
OAuth state cookie. Navigate the browser to url. The backend binds state to the selected provider and stores an encrypted
copy of the current invitation code hash, so a resend that rotates the invitation
invalidates an already-started flow. The returned provider address must match
the invited email. A different address is rejected without consuming the invite.
An expired, revoked or consumed invitation cannot create an account.
Confirm an invited provider email
POST /auth/oauth/invite/verify
Only needed when the provider did not confirm ownership of the invited address.
The callback has already mailed a code and returned the invitation challenge ID.
No account exists yet. The pending provider profile is encrypted in storage.
Auth: Public, with the same browser's invitation-binding cookie and CSRF.
{ "challengeId": "...", "code": "123456" }The one-time code uses VerificationCodeTTL, bounded by invitation expiry, and
allows five guesses. Successful proof, account creation, provider connection,
and invitation consumption commit together. An existing email is never silently
connected to the provider; sign into that account and connect it explicitly.
Success sets the normal session/refresh cookies and returns
200 {"step":"authenticated","isNewUser":true}, or returns step: "two_factor", challengeId,
codeSent and expiresAt with a challenge-binding cookie when local 2FA is
required. Complete that challenge through /auth/2fa/verify. A failed account
creation leaves the invitation and email proof available for retry.
Next
- Sessions: refresh, list, revoke, CSRF token
- Organizations: orgs, members, invites, active org