switching clears the token & responses
No token
15

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: no-store. (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.
  • trueSafe to bounce the user to the supplied return_to after they authenticate. Only ever true on an exact normalized-string match.
  • falseFall 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 }. Three possible answers: { accessToken, … } (nothing left to prove — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — continue at scenario 11/12/13), or the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12). 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. The answer is one of three shapes — tokens, a challenge chain, or the legacy MFA challenge — and 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 means16 fields

200 OK, { data } envelope, Cache-Control: no-store. THREE possible shapes, and the login response itself is the only way to learn which branch 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. Only the token branch sets Set-Cookie: refresh_token.

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.
  • BearerSend the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Non-MFA branch only. Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Client should refresh before it elapses.
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_codeNew browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otpNew browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otpAn enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totpAn 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_otpSend/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totpRead 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).
  • trueMFA 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.