Skip to main content

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.

BucketPurpose
Local authEmail + password register / login / forgot / reset / change
MFATOTP setup / enable / disable / verify; backup codes
OAuth socialGoogle / GitHub / Microsoft browser-redirect login
Enterprise SSOOIDC + SAML 2.0 with SLO and forced-MFA exchange
Sessions & tokensJWT refresh, logout, per-session revoke, stream tokens
Profile & orgme, org switch, preferences, session timeout
API KeysWorkspace-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

EndpointPurpose
POST /api/v1/auth/registerSingle-call signup (name + email + password + organizationName)
POST /api/v1/auth/request-signupSend a verification email; returns no JWT until completion
POST /api/v1/auth/complete-signupExchange the email-verification token for the first JWT
POST /api/v1/auth/loginEmail + password login; returns JWT or MFA-pending challenge
POST /api/v1/auth/forgot-passwordSend a password-reset email; always returns 200 for enumeration safety
POST /api/v1/auth/reset-passwordExchange the reset token for a new password
POST /api/v1/auth/change-passwordAuthenticated change with current-password gate
POST /api/v1/auth/magic-linkSend a one-time magic-link email
POST /api/v1/auth/magic-link/verifyExchange 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 — login
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

EndpointPurpose
POST /api/v1/auth/mfa/setupGenerate a TOTP secret + QR code; not yet active
POST /api/v1/auth/mfa/enableVerify the first TOTP code and activate MFA
POST /api/v1/auth/mfa/disableDeactivate MFA (TOTP gate required)
POST /api/v1/auth/mfa/loginExchange the login tempToken + TOTP for a JWT
POST /api/v1/auth/backup-codes/generateMint a fresh pack of one-time backup codes
POST /api/v1/auth/mfa/resendReturns 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:

EndpointPurpose
GET /api/v1/auth/googleStart Google OAuth
GET /api/v1/auth/google/callbackGoogle return URL; sets adjudon_oauth_token httpOnly cookie + redirects
GET /api/v1/auth/githubStart GitHub OAuth (user:email scope required)
GET /api/v1/auth/github/callbackGitHub return URL
GET /api/v1/auth/microsoftStart Microsoft OAuth (prompt=select_account)
GET /api/v1/auth/microsoft/callbackMicrosoft return URL
GET /api/v1/auth/oauth-tokenFrontend 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.

EndpointProtocolPurpose
POST /api/v1/auth/sso/initiatedispatcherEmail-domain routing to OIDC or SAML
GET /api/v1/auth/sso/callbackOIDCOIDC return URL
POST /api/v1/auth/saml/initiateSAMLProvider-direct SAML start
POST /api/v1/auth/saml/callbackSAMLHTTP-POST binding return URL (5 MB body limit; SAMLResponse form-encoded XML)
GET /api/v1/auth/saml/metadata/:orgIdSAMLPublic Service-Provider metadata XML for IdP setup
POST /api/v1/auth/saml/logout/initiateSAMLSP-initiated Single Logout (SLO)
POST /api/v1/auth/saml/logout/callbackSAMLSLO callback (signed LogoutResponse)
GET /api/v1/auth/sso/mfa-exchangedispatcherExchange 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

EndpointPurpose
POST /api/v1/auth/refreshMint a fresh JWT if the current one is still valid
POST /api/v1/auth/logoutBlacklist the caller's current jti for the JWT's remaining lifetime
POST /api/v1/auth/revoke-sessionsBlacklist every active jti for the user; forces re-login everywhere
POST /api/v1/auth/revoke-sessionBlacklist a single jti by audit-log ID
POST /api/v1/auth/stream-tokenMint a 60-second single-use token for SSE / WebSocket auth (JWT never goes in URL)
PATCH /api/v1/auth/me/session-timeoutOne 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

EndpointPurpose
GET /api/v1/auth/meReturn the caller's user + active org
GET /api/v1/auth/me/orgsList every org the user is a member of
POST /api/v1/auth/switch-orgMint a JWT scoped to a different org the user belongs to
PATCH /api/v1/auth/profileUpdate name, locale, timezone
PATCH /api/v1/auth/me/preferencesDashboard preferences (notification opt-outs, etc.)
GET /api/v1/auth/security-settingsRead MFA state, password-age, trusted devices, etc.
GET /api/v1/auth/login-activityRead 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

EndpointPurpose
POST /api/v1/auth/keysMint a workspace-scoped adj_live_* key (shown once)
GET /api/v1/auth/keysList keys (prefix only; full key never re-readable)
PATCH /api/v1/auth/keys/:idUpdate name or scope.agentIds / scope.readOnly
DELETE /api/v1/auth/keys/:idRevoke 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

  • authLimiter is 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-password always returns 200. 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: true on 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 /keys returns the key once; thereafter only the prefix is retrievable. There is no recovery path.

See also