switching clears the token & responses
No token
16

Entering a partner app (cross-app login)

Signing in

A relying app (e.g. web-it-cms) sends the user to IAM to sign in and wants them bounced back afterwards. IAM validates the return URL against the registered client BEFORE rendering anything, which is where the open-redirect hole is closed. Behind OAUTH_ENABLED — the route 404s when off. THIS IS MODEL A, AND SCENARIO 16 SUPERSEDES IT: here the app asks IAM to validate a URL and IAM renders the login; there, the app sends a standards OAuth 2.1 authorization request and receives its OWN app-scoped tokens rather than riding the shared session. The two are gated by different flags (OAUTH_ENABLED here, MULTI_APP_ENABLED there) and can be rolled out independently, but an app that has moved to Model B has no use for this probe.

  1. GET /v1/oauth/clients/{clientId}/validate-return?return_to=… → always 200: { valid: true, displayName } on an exact match, else { valid: false }
  2. Only if valid — render the sign-in form and authenticate normally (any of scenarios 9–14)
  3. On success bounce to the VALIDATED return_to; on valid:false fall back to a safe default and never echo the supplied URL
GET/v1/oauth/clients/{clientId}/validate-returnpublicValidate a return_to URL

ALWAYS 200 — an unknown client and an unregistered URI return the identical { valid: false } with no reason, so the client registry cannot be enumerated. Matching is exact normalized-string equality (never startsWith, a regex, or a same-origin test): https only, except http for localhost / 127.0.0.1, and URLs carrying a fragment are rejected outright. Rejections append an oauth_redirect_rejected security event and the return_to value itself is NEVER recorded. Clients and their URIs are operator-registered configuration — there is no self-service registration endpoint. 404 when OAUTH_ENABLED is false.

Path parameters
Query parameters
ℹ︎Field guide — what each value means & where it comes from2 fields

Public and unauthenticated (no gateway context). The login UI asks whether a return_to URL is a REGISTERED redirect URI for a client before bouncing the user there — this is where the open-redirect hole is closed. Behind the OAUTH_ENABLED kill-switch: the route 404s when it is off. Cache-Control: no-store, throttled per IP.

clientIdpathstringrequiredyou choose
The relying app's registered client id (e.g. web-it-cms) — a slug matching ^[a-z0-9]+(-[a-z0-9]+)*$, at most 64 chars. Clients and their URIs are OPERATOR-REGISTERED configuration; there is no self-service registration endpoint. An unknown client is indistinguishable from an unregistered URI in the response.
return_toquerystringrequiredyou choose
The URL the relying app wants the user bounced back to, at most 2048 chars. Matching is EXACT normalized-string equality — never startsWith, a regex, or a same-origin test: https only, except http for localhost / 127.0.0.1, and any URL carrying a fragment is rejected outright. On rejection an oauth_redirect_rejected security event is appended and this value is NEVER recorded.
→ GET https://api.kerja.team/v1/oauth/clients/web-it-cms/validate-return?return_to=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback
↩︎Response guide — what comes back & what each value means2 fields

ALWAYS 200 — an unknown client and an unregistered URI return the IDENTICAL body with no reason, so the client registry cannot be enumerated. Cache-Control: private, max-age=60 — browser-cacheable for a minute, never shared-cacheable (formerly no-store, which made every page refresh re-spend the per-IP throttle). (When OAUTH_ENABLED is false the route is not mounted at all and answers 404.)

data.validbooleanalways
Whether return_to is a registered redirect URI of an ACTIVE client.
  • true — Safe to bounce the user to the supplied return_to after they authenticate. Only ever true on an exact normalized-string match.
  • false — Fall back to a safe default and NEVER echo the supplied URL. The same body is returned for an unknown client, an unregistered URI, an http (non-localhost) URL, and one carrying a fragment. A rejection appends an oauth_redirect_rejected security event, and the return_to value itself is never recorded.
data.displayNamestringconditional
The registered client's human-readable name (e.g. “IT CMS”), present only when valid is true — so the login screen can say which app is asking.
POST/v1/auth/loginpublicLog in (identifier + password)

identifier is an email OR an E.164 phone — the shape decides the lookup ({ email } is still accepted as a deprecated alias) — and it is OPTIONAL on a recognised device, where the device_token cookie names the account and the body needs only { password }. A 200 HERE IS A BODY TO READ, NOT A STATUS TO TRUST — five answers share it: { accessToken, … } (signed in — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — scenario 11/12/13), the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12), { noOrganization: true, bootstrapToken, expiresIn } (the credential is good but you belong nowhere — scenario 15), or { invitationPending: true, acceptToken, expiresIn, invitations[] } (your only seat is an invitation you have not accepted — scenario 52). The last two mint NOTHING session-shaped: no session, no refresh or device cookie, no access token. Gated by the LOGIN policy before the credential is even checked → 403 login_method_disabled (scenario 7).

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from4 fields

Public, unauthenticated login. No Authorization header; send only the JSON body below. Throttled per credential/IP; response is no-store. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. Which one you get is NOT checkable beforehand (there is no pre-login MFA-status endpoint, by anti-enumeration design). Before the credential is examined at all, the active LOGIN policy is checked: if the channel this attempt uses is off, the answer is 403 login_method_disabled for a right and a wrong password alike (which discloses nothing — GET /v1/auth/login-policy publishes the same fact unauthenticated). Read that policy first in scenario 7.

identifierbodystringconditionalyou choose
The credential being authenticated: an email OR an E.164 phone number. The SHAPE decides the lookup, not a mode flag — anything matching ^\+[1-9]\d{6,14}$ is resolved by phone, anything else as an email — so a client never has to know which kind of account it is addressing. The credential you type must itself be VERIFIED (an email login needs a verified email, a phone login a verified phone). OPTIONAL ON A RECOGNISED DEVICE: when the body carries no identifier and a valid device_token cookie is presented, the account is resolved from the trusted device and its primary channel is used. That is what lets the “Welcome back” screen (scenario 8) ask for nothing but a password — it only ever had a MASKED identifier to show, and could not otherwise complete the login it had just offered. It grants nothing: the password is still required, and a missing / garbage / expired / revoked cookie with no identifier answers the SAME uniform 401 invalid_credentials as a wrong password (never a 422 “identifier is required”, which would itself confirm the cookie was rejected). An explicit identifier always takes precedence.
emailbodystringconditionalyou choose
DEPRECATED ALIAS for identifier, still accepted and normalised server-side so existing clients keep working. Send identifier in new integrations. Valid email, at most 320 chars.
clientIdbodystringoptionalyou choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
passwordbodystringrequiredyou choose
The account password the user types. 1-1024 chars. Verified against the stored credential; wrong password yields a uniform invalid_credentials 401.
→ POST https://api.kerja.team/v1/auth/login
↩︎Response guide — what comes back & what each value means23 fields

200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which applies (there is no pre-login MFA-status endpoint, by anti-enumeration design): (1) tokens — nothing more to prove; (2) a CHALLENGE CHAIN { challengeToken, challenge, remaining } when a device and/or MFA hop is pending, continued at POST /v1/auth/login/challenge/verify; (3) the LEGACY single-hop { mfaRequired, method, phoneHint, mfaToken } when DEVICE_TRUST_ENABLED is off; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.

data.accessTokenstringconditional
Non-MFA branch only (MFA disabled). The signed JWT access token; the client sends it as Authorization: Bearer for subsequent calls.
data.tokenTypestringconditional
Non-MFA branch only. Token scheme to use in the Authorization header.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
data.user.idstringconditional
Non-MFA branch only. The authenticated user's unique id.
data.user.emailstringconditional
Non-MFA branch only. The authenticated user's email.
data.user.displayNamestringconditional
Non-MFA branch only. Human-readable display name for the user, e.g. for UI greeting.
data.user.tenantIdstringconditional
Non-MFA branch only. The selected tenant for this session; if the user has multiple tenants, the default membership is chosen.
data.challengeTokenstringconditional
CHALLENGE branch only. The token that carries the ordered remaining plan and the accumulated amr — the server keeps no per-chain state. Copy it into POST /v1/auth/login/challenge/verify (or .../resend). Short-lived: LOGIN_CHALLENGE_TTL_SECONDS, default 300s.
data.challenge.typeenumconditional
CHALLENGE branch only. Which factor the first hop collects. A device hop always comes before an MFA hop, so the MFA hint is never leaked to an unrecognised machine.
  • device_email_code — New browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otp — New browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otp — An enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totp — An enrolled authenticator app — nothing is sent; the code comes from the app, so no resend applies.
data.challenge.hintstringconditional
CHALLENGE branch only. The masked destination the code went to (b••••@example.com, +••••••6789) — show it so the user knows which inbox or handset to check.
data.challenge.expiresInnumberconditional
CHALLENGE branch only. Seconds until this hop expires; when it lapses, start again at login.
data.remainingnumberconditional
CHALLENGE branch only. How many hops are still to clear, counting this one: 1 for a lone device or MFA challenge, 2 for the new-device-plus-MFA chain (LN3, scenario 13).
data.methodenumconditional
LEGACY MFA branch only. Which second factor to collect.
  • phone_otp — Send/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totp — Read the code from the authenticator app — there is nothing to send, so skip the otp/request step.
data.phoneHintstringconditional
LEGACY MFA branch, phone_otp only. The masked destination the second-factor OTP will go to.
data.mfaRequiredbooleanconditional
MFA branch only (MFA enabled). Signals no token was issued and the caller must complete the MFA challenge via POST /v1/auth/mfa/verify (§3.8).
  • true — MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional
MFA branch only. A single-purpose JWT (~5 min TTL), NOT an access token. The client copies it into the follow-up MFA verify (and OTP request) calls to complete login.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.