Authentication
The Authentication API covers every path a human user takes into
the Adjudon dashboard: local password login, Google / GitHub /
Microsoft OAuth, enterprise OIDC and SAML 2.0 SSO, multi-factor
verification, magic-link login, session management, and the
bearer-API-key surface. The endpoints surface a single output for
authenticated callers — an Adjudon JWT — and a separate
output for SDK callers, the workspace API key. Tested against API
version v1. Mounted at /api/v1/auth. The authLimiter caps
this resource at 100 requests per IP per 15 minutes; abusive auth
patterns return 429 RATE_LIMIT_EXCEEDED ahead of any other
verdict.
How a JWT actually flows
A successful login — whether by password, OAuth callback,
or SSO completion — mints one signed JWT carrying the
user's _id, the active organizationId, the assigned role,
and a jti (JWT ID) that uniquely identifies this specific
session. The JWT lifetime is configurable per user from one hour
to seven days via PATCH /me/session-timeout; the default is
seven days.
Every authenticated request flows through the protect()
middleware, which verifies the JWT signature, checks that the
jti is not on the blacklist, and stamps req.user with the
user record plus the organizationId for downstream
authorisation. A stale or blacklisted jti returns
401 SESSION_REVOKED; an expired JWT returns
401 TOKEN_EXPIRED. The blacklist is the single mechanism for
revocation across local-auth, OAuth, and SSO sessions; the seven
buckets all funnel into the same revocation surface.
The seven buckets
The endpoint surface is large because identity is large; the buckets below are how to read it.
| Bucket | Purpose |
|---|---|
| Local auth | Email + password register / login / forgot / reset / change |
| MFA | TOTP setup / enable / disable / verify; backup codes |
| OAuth social | Google / GitHub / Microsoft browser-redirect login |
| Enterprise SSO | OIDC + SAML 2.0 with SLO and forced-MFA exchange |
| Sessions & tokens | JWT refresh, logout, per-session revoke, stream tokens |
| Profile & org | me, org switch, preferences, session timeout |
| API Keys | Workspace-scoped adj_live_* key CRUD |
A few peripheral endpoints (passkeys, trusted devices, password history, account unlock) are wired but mounted on a placeholder controller today — the surface exists, the implementations are deliberately staged. Disclosed inline below.
Local auth
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/register | Single-call signup (name + email + password + organizationName) |
POST /api/v1/auth/request-signup | Send a verification email; returns no JWT until completion |
POST /api/v1/auth/complete-signup | Exchange the email-verification token for the first JWT |
POST /api/v1/auth/login | Email + password login; returns JWT or MFA-pending challenge |
POST /api/v1/auth/forgot-password | Send a password-reset email; always returns 200 for enumeration safety |
POST /api/v1/auth/reset-password | Exchange the reset token for a new password |
POST /api/v1/auth/change-password | Authenticated change with current-password gate |
POST /api/v1/auth/magic-link | Send a one-time magic-link email |
POST /api/v1/auth/magic-link/verify | Exchange a magic-link token for a JWT |
Password policy is enforced at validation time: 10 character minimum, at least one uppercase, lowercase, and digit. Password history (preventing recent reuse) is on the placeholder controller today.
curl -X POST https://api.adjudon.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "[email protected]", "password": "..." }'
A successful login returns { token, user }. If the user has MFA
enabled the response is { mfaRequired: true, tempToken }; the
client posts the tempToken plus the user's TOTP to
POST /api/v1/auth/mfa/login to mint the real JWT.
MFA
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/mfa/setup | Generate a TOTP secret + QR code; not yet active |
POST /api/v1/auth/mfa/enable | Verify the first TOTP code and activate MFA |
POST /api/v1/auth/mfa/disable | Deactivate MFA (TOTP gate required) |
POST /api/v1/auth/mfa/login | Exchange the login tempToken + TOTP for a JWT |
POST /api/v1/auth/backup-codes/generate | Mint a fresh pack of one-time backup codes |
POST /api/v1/auth/mfa/resend | Returns a static informational message; TOTP cannot be "resent" because codes rotate every 30 s |
Backup codes are single-use and replace the entire prior set on
each /generate call — an org's stale codes from a year ago
are mathematically inert.
OAuth social
OAuth uses Passport strategies and the browser-redirect flow:
| Endpoint | Purpose |
|---|---|
GET /api/v1/auth/google | Start Google OAuth |
GET /api/v1/auth/google/callback | Google return URL; sets adjudon_oauth_token httpOnly cookie + redirects |
GET /api/v1/auth/github | Start GitHub OAuth (user:email scope required) |
GET /api/v1/auth/github/callback | GitHub return URL |
GET /api/v1/auth/microsoft | Start Microsoft OAuth (prompt=select_account) |
GET /api/v1/auth/microsoft/callback | Microsoft return URL |
GET /api/v1/auth/oauth-token | Frontend exchange: read the httpOnly cookie, return the JWT in JSON body |
The token never appears in a URL. The callback sets a 7-day
httpOnly cookie (Secure + SameSite=None in production); the
frontend's /auth/callback route reads the cookie via
/oauth-token and clears the cookie. This isolates the JWT from
the browser history, referrers, and analytics scripts.
A successful OAuth login writes one auth.login entry to the
Operations Audit Log with
metadata.method = 'oauth'.
Enterprise SSO
OIDC and SAML 2.0 are gated by the sso feature (Governance+).
The dispatcher at /sso/initiate reads the user's email-domain,
looks up the org's ssoConfig.provider, and routes to the
matching protocol.
| Endpoint | Protocol | Purpose |
|---|---|---|
POST /api/v1/auth/sso/initiate | dispatcher | Email-domain routing to OIDC or SAML |
GET /api/v1/auth/sso/callback | OIDC | OIDC return URL |
POST /api/v1/auth/saml/initiate | SAML | Provider-direct SAML start |
POST /api/v1/auth/saml/callback | SAML | HTTP-POST binding return URL (5 MB body limit; SAMLResponse form-encoded XML) |
GET /api/v1/auth/saml/metadata/:orgId | SAML | Public Service-Provider metadata XML for IdP setup |
POST /api/v1/auth/saml/logout/initiate | SAML | SP-initiated Single Logout (SLO) |
POST /api/v1/auth/saml/logout/callback | SAML | SLO callback (signed LogoutResponse) |
GET /api/v1/auth/sso/mfa-exchange | dispatcher | Exchange the SSO-MFA tempToken when org.requireMfaEvenWithSso is on |
When an org has ssoConfig.requireMfaEvenWithSso: true, the
OIDC / SAML callback does not mint a JWT directly — it
sets a tempToken cookie and redirects the browser to
?ssoMfa=1. The frontend reads tempToken via
/sso/mfa-exchange, prompts the user for TOTP, and posts both to
/mfa/login. This closes the regulator gap that "SSO ≡ MFA"
(it is not; SSO is a delegation, MFA is a second factor).
Adjudon's SAML metadata endpoint advertises an encryption key
descriptor when SAML_SP_DECRYPTION_KEY is configured; encrypted
assertions are processed transparently. Without the env var, the
endpoint negotiates unencrypted assertions only.
Sessions & tokens
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/refresh | Mint a fresh JWT if the current one is still valid |
POST /api/v1/auth/logout | Blacklist the caller's current jti for the JWT's remaining lifetime |
POST /api/v1/auth/revoke-sessions | Blacklist every active jti for the user; forces re-login everywhere |
POST /api/v1/auth/revoke-session | Blacklist a single jti by audit-log ID |
POST /api/v1/auth/stream-token | Mint a 60-second single-use token for SSE / WebSocket auth (JWT never goes in URL) |
PATCH /api/v1/auth/me/session-timeout | One of 1h, 8h, 24h, 7d |
Token blacklist checks run on every authenticated request; a
revoked jti returns 401 SESSION_REVOKED regardless of the
JWT's expiry.
Profile & org switch
| Endpoint | Purpose |
|---|---|
GET /api/v1/auth/me | Return the caller's user + active org |
GET /api/v1/auth/me/orgs | List every org the user is a member of |
POST /api/v1/auth/switch-org | Mint a JWT scoped to a different org the user belongs to |
PATCH /api/v1/auth/profile | Update name, locale, timezone |
PATCH /api/v1/auth/me/preferences | Dashboard preferences (notification opt-outs, etc.) |
GET /api/v1/auth/security-settings | Read MFA state, password-age, trusted devices, etc. |
GET /api/v1/auth/login-activity | Read the per-user login audit slice |
POST /switch-org mints a fresh JWT bound to the new
organizationId. The old token's jti is not auto-blacklisted
— the user can hold valid tokens for two orgs at once if they
keep both browser windows open. Logging out blacklists only the
jti the request carries.
API Keys
| Endpoint | Purpose |
|---|---|
POST /api/v1/auth/keys | Mint a workspace-scoped adj_live_* key (shown once) |
GET /api/v1/auth/keys | List keys (prefix only; full key never re-readable) |
PATCH /api/v1/auth/keys/:id | Update name or scope.agentIds / scope.readOnly |
DELETE /api/v1/auth/keys/:id | Revoke immediately |
Per-agent keys (adj_agent_*) live on the
Agents API; workspace keys live here.
The two formats and scopes do not overlap.
Placeholder endpoints (wired, staged)
The following endpoints are mounted but route to a placeholder controller today; they return well-formed but informational responses pending implementation:
/passkeys/registration/*, /passkeys/authentication/*,
/passkeys/:id, /trusted-devices/:deviceId,
/account/unlock-request, /password-history/check,
/verify-email, /resend-verification. The /mfa/resend endpoint
is also placeholder by design (TOTP cannot be re-sent — codes
rotate every 30 seconds).
This is disclosed honestly because the surface is published in the OpenAPI spec; an SDK that introspects the spec must know that calling these endpoints today is not a no-op but is also not yet the full feature. Implementations land per the Versioning policy.
Common gotchas
authLimiteris the strictest tier. 100 req per IP per 15 min on every/auth/*endpoint — this is the credential-stuffing brake. Legitimate users hit it only when scripted; humans never.forgot-passwordalways returns200. Email enumeration is a vulnerability; the endpoint behaves identically whether the email exists or not.- OAuth tokens are httpOnly-cookie-mediated. The JWT never
appears in a redirect URL or query parameter; the frontend
exchanges the cookie via
/oauth-token. Treat the cookie as the auth handoff, not the persistent session. - SSO MFA is opt-in per org. Set
ssoConfig.requireMfaEvenWithSso: trueon the Organization to force TOTP after SSO. Default is off — SSO alone is treated as sufficient unless the org configures otherwise. - API key responses include the plaintext exactly once.
POST /keysreturns the key once; thereafter only the prefix is retrievable. There is no recovery path.
See also
- Authentication overview — the conceptual primer on JWT vs API keys
- SCIM 2.0 — the IdP provisioning surface that complements SSO
- Audit Log API — where every
auth.*action lands - Agents API — per-agent
adj_agent_*API key surface - Plans & Features —
the
ssoandscimfeature gates - Error Codes — the broader error taxonomy