switching clears the token & responses
No token
16

Signing in through an app's authorization request (OAuth 2.1)

Signing inMULTI_APP_ENABLED

The full Model B front door, and the successor to scenario 15: the app does not ask IAM to validate a URL and then render its own login — it sends a standards OAuth 2.1 authorization request and receives a code it redeems for its OWN app-scoped tokens. The app must already be registered (scenario 41) and granted to the member (scenario 49).

Every endpoint on this page sits behind MULTI_APP_ENABLED and answers 404 when it is off. A 404 here means the flag, not a wrong path.

  1. GET /v1/oauth/authorize — a browser TOP-LEVEL NAVIGATION carrying client_id, redirect_uri, response_type=code, code_challenge and code_challenge_method=S256. Answered with a 302, never JSON
  2. Only when there is NO live session — step 1 redirected to IAM_LOGIN_URL?return=…; sign in with any login scenario (9–14), then the browser replays step 1, which now finds the iam_sso cookie
  3. POST /v1/oauth/token — the app redeems { grant_type, client_id, code, code_verifier, redirect_uri } for its own access token (azp = the app) and a refresh credential on its one registered channel
  4. POST /v1/auth/apps/{clientId}/refresh — ongoing, rotates THAT app's chain only. /v1/auth/refresh is retired wherever MULTI_APP_ENABLED is on
GET/v1/oauth/authorizepublicAuthorization request (browser navigation)

NOT callable with fetch: it answers 302 and carries NO CORS headers under any circumstances (per OAuth 2.1), because it is reached by navigation. Use ⟳ Generate then “Open in a new tab”, and paste the ?code= you land on back into the box — it is handed to the token card below together with the matching verifier. THREE outcomes, all redirects: live session + app granted and enabled → ?code=; live session WITHOUT the grant → ?error=access_denied back to the APP (bouncing a signed-in-but-unentitled user to login would create a loop in which signing in successfully cannot fix the reason for the bounce); no live session → IAM_LOGIN_URL?return=…. The one non-redirect answer is 422, identical for an unknown client_id and an unregistered redirect_uri, with no echo of the submitted URI — there is nowhere safe to redirect an error to, and bouncing to an attacker-supplied URI to report it would be the open redirect this endpoint exists to prevent. Authenticated by the iam_sso cookie alone (HttpOnly; Secure; SameSite=Lax; Path=/v1/oauth/authorize) — Lax is required rather than chosen, because the cookie must ride a cross-site top-level GET, and the exact Path is what pays for it. 404 when MULTI_APP_ENABLED is off.

🔑 PKCE

Press Generate. The challenge goes out with this request; the verifier stays in the tab and is filled into POST /v1/oauth/token further down, because a mismatch there answers a deliberately undifferentiated 400 invalid_grant.

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

The OAuth 2.1 authorization endpoint — reached by browser TOP-LEVEL NAVIGATION and answered with a 302, never a JSON body. Authenticated by the iam_sso cookie only: no Authorization header and no gateway context. CORS is not enabled on this route under any circumstances (per OAuth 2.1), so it cannot be called by fetch — this card builds the URL and opens it in a new tab instead. Three outcomes, ALL redirects: a live session with the app granted and enabled → redirect_uri?code=…&state=…&iss=…; a live session WITHOUT the grant → redirect_uri?error=access_denied (back to the APP, because bouncing a signed-in-but-unentitled user to login creates a loop in which signing in successfully cannot fix the reason for the bounce); no live session → IAM_LOGIN_URL?return=<the authorization request>. The one non-redirect answer is 422. Behind MULTI_APP_ENABLED — 404 when off.

client_idquerystringrequiredfrom a response
The OAuth client requesting authorization, from the registry. An unknown client_id is a 422 — identical to an unregistered redirect_uri, with no echo of the submitted URI, because there is nowhere safe to redirect an error to and bouncing to an attacker-supplied URI to report it would be the open redirect this endpoint exists to prevent. A coarse app_code_rejected reason is audited; the URI itself is never stored.
redirect_uriquerystring (URL)requiredfrom a response
Where to send the browser with the code. Must match a URI registered for this client EXACTLY. An unregistered value is a 422, indistinguishable from an unknown client_id.
response_typequeryenumrequiredyou choose
The OAuth response type. Only the authorization-code flow exists here.
  • codeThe authorization-code flow — the only accepted value. The code is redeemed at POST /v1/oauth/token.
code_challengequerystringrequiredclient-generated
The PKCE challenge: BASE64URL(SHA-256(code_verifier)). Generate a fresh random verifier per attempt and keep it — POST /v1/oauth/token needs the verifier that matches this challenge. The ⟳ generate button on this card creates both and carries the verifier over for you.
code_challenge_methodqueryenumrequiredyou choose
How the challenge was derived. S256 is mandatory: both plain AND a MISSING method are refused at the validation gate — a missing method IS the downgrade PKCE exists to prevent.
  • S256SHA-256. The only accepted value.
  • plainRefused at the validation gate.
statequerystringoptionalclient-generated
Opaque round-trip value, echoed on every redirect outcome including error=access_denied. Use it to tie the response back to the request you started.
GET https://api.kerja.team/v1/oauth/authorize?client_id=web-it-cms&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&response_type=code&code_challenge_method=S256&state=xyz
↩︎Response guide — what comes back & what each value means6 fields

NOT a JSON endpoint: answered with a 302 and no body, and with CORS disabled under any circumstances, so it cannot be read by fetch. Use this card's ⟳ generate + open-in-new-tab controls and read the outcome from the URL the browser lands on. Three outcomes, all redirects — ?code= (live session, app granted and enabled), ?error=access_denied back to the APP (live session, app not permitted — sending a signed-in-but-unentitled user to login would create a loop in which signing in successfully cannot fix the reason for the bounce), or IAM_LOGIN_URL?return=… (no live session). The one non-redirect answer is 422, identical for an unknown client_id and an unregistered redirect_uri, with no echo of the submitted URI. Cache-Control: no-store.

Locationheader (URL)always
Where the browser is being sent — the entire result of this endpoint. Which of the three shapes below it takes is the answer you came for.
Location ?codestringconditional
Present on the success redirect to redirect_uri. Redeem it at POST /v1/oauth/token together with the code_verifier that matches the challenge you sent. Tiny-lived (APP_AUTH_CODE_TTL_SECONDS, default 60) because it travels in the URL bar and leaks through history, Referer and logs.
Location ?statestringconditional
Echoes the state you sent, on BOTH the success and the access_denied redirect. Use it to tie the response back to the request.
Location ?issstringconditional
The issuer identifier, on the redirects back to redirect_uri. Lets the client confirm which IAM answered.
Location ?errorenumconditional
Present instead of code when the session is live but the app is not permitted.
  • access_deniedThe caller is signed in but does not hold the app (or the tenant is not enabled for it). Delivered to the APP, never to the login screen — grant the app at PUT /v1/users/{userId}/apps/{appCode} or enable the tenant at PUT /v1/admin/tenant-apps.
Location ?returnstringconditional
Only on the redirect to IAM_LOGIN_URL, carrying the whole authorization request. After signing in, the browser replays it and this endpoint now finds the iam_sso cookie.
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.
POST/v1/oauth/tokenpublicRedeem the code for this app's tokens

ONE redemption path for both acquisition routes — the authorize redirect (16) and the hand-off (17) mint the same row, differing only in issued_via. code_verifier is auto-filled from whichever card generated the challenge. The order of checks is part of the contract: client_id, redirect_uri and the verifier are validated BEFORE used_at, because rejecting a spent code first would answer before knowing whether the replay was otherwise valid. Replay = burn always, revoke conditionally: any second use is 400 invalid_grant and the code stays spent, but only a replay with ALL bindings valid also revokes the chain that code issued — a wrong verifier revokes nothing, else an attacker holding an intercepted but unusable code could log the victim out at will. The refresh credential arrives on the client's ONE registered channel (rt_<client_id> cookie, or refreshToken in the body) — never both, never the caller's choice. 404 when MULTI_APP_ENABLED is off.

🔑 PKCEno verifier yet — generate one on the authorize / hand-off card above
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from5 fields

Redeems an authorization code for tokens. ONE redemption path for both acquisition routes — GET /v1/oauth/authorize and POST /v1/oauth/handoff mint the same row, differing only in issued_via. The ORDER OF CHECKS is part of the contract: client_id, redirect_uri and the PKCE verifier are validated BEFORE used_at is consulted, because the natural shape (reject a spent code first) makes the replay policy impossible — it would answer before knowing whether the replay was otherwise valid. Replay = burn always, revoke conditionally: any second use is 400 invalid_grant and the code stays spent, but only a replay with ALL bindings valid additionally revokes the chain that code issued; a replay with a wrong verifier revokes NOTHING, because otherwise an attacker holding an intercepted but unusable code could log the victim out at will. Errors are undifferentiated across unknown/spent/expired/mismatched — the response never tells an attacker which guess was closest. Behind MULTI_APP_ENABLED.

grant_typebodyenumrequiredyou choose
The OAuth grant being exercised.
  • authorization_codeRedeem a code minted by /v1/oauth/authorize or /v1/oauth/handoff. The only accepted value here.
client_idbodystringrequiredfrom a response
The client redeeming the code. Must be the client the code was issued to. Because the id travels in the BODY, CORS on this route is registry-wide across active body clients — a preflight carries only Origin — and the per-client binding is enforced here instead, where the code identifies the client and redirect_uri is matched exactly.
codebodystringrequiredfrom a response
The authorization code. From the ?code= query param the browser landed on after /v1/oauth/authorize, or from the { code } returned by /v1/oauth/handoff. Tiny-lived (APP_AUTH_CODE_TTL_SECONDS, default 60) because it travels in the URL bar and leaks through history, Referer and logs. Spent by a conditional UPDATE, so two simultaneous exchanges yield exactly one 200 and one 400.
code_verifierbodystringrequiredclient-generated
The PKCE verifier whose SHA-256 is the code_challenge sent to /v1/oauth/authorize (or the codeChallenge sent to /v1/oauth/handoff). This is the secret that makes an intercepted code useless — a wrong verifier is denied and audited, and deliberately revokes nothing.
redirect_uribodystring (URL)requiredyou choose
Must match the redirect_uri sent to /v1/oauth/authorize exactly. Validated before used_at is consulted.
POST https://api.kerja.team/v1/oauth/token
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the app's OWN tokens — an access token whose azp is this app, plus a refresh credential delivered on the client's ONE registered channel. Cache-Control: no-store. Delivery is a registry property and never selectable by the caller: a 'cookie' client receives rt_<client_id> (HttpOnly; Secure; SameSite=Strict; Path=/v1/auth/apps/<client_id>/refresh) and NOT refreshToken in the body; a 'body' client receives it in the body and not as a cookie. Never both. Errors: 400 invalid_grant undifferentiated across unknown/spent/expired/mismatched, 403 when the grant or enablement was revoked in the 60-second gap, 404 when MULTI_APP_ENABLED is off.

dataobjectalways
The token payload for this app.
data.accessTokenstring (JWT)always
The app-scoped access token — azp names THIS client, which is what the gateway enforces per route at the edge. Auto-captured by this tester as the active bearer.
data.refreshTokenstringconditional
Present ONLY for a 'body' client (a cross-site app on an unrelated registrable domain). Reachable by page JS — that is the whole cost, and why its TTL is CROSS_SITE_REFRESH_TOKEN_TTL_SECONDS (8h) rather than the full REFRESH_TOKEN_TTL_SECONDS: it cannot be bounded by HttpOnly. Absent for a 'cookie' client, where the credential arrives as Set-Cookie instead.
data.expiresIninteger (seconds)conditional
Lifetime of the access token.
data.tokenTypestringconditional
Bearer.
Set-Cookie rt_<client_id>headerconditional
The refresh credential for a 'cookie' client: HttpOnly; Secure; SameSite=Strict, scoped Path=/v1/auth/apps/<client_id>/refresh so the browser attaches it nowhere else. Re-set on EVERY rotation — a client that falls behind the chain reads as a replay on its next renewal.
POST/v1/auth/apps/{clientId}/refreshpublicRotate THIS app's refresh chain

The chain is keyed (tenant, session, client), so rotating here cannot invalidate another app open under the same session — that isolation is what lets two products stay open in two tabs. Cookie clients send {} and the rt_<client_id> cookie rides along; body clients put { refreshToken } in the body. Reuse detection nonetheless stays SESSION-WIDE: a genuinely replayed token revokes every app. Per-chain isolation removed the false positives, not the policy. Two tabs of the SAME app share one chain and are made safe by a row lock on rotation, not by the per-app split. Retires POST /v1/auth/refresh wherever MULTI_APP_ENABLED is on — that route answers 404 there. 401 for missing/expired/replayed/wrong-chain; 403 when the grant or enablement was revoked mid-session.

Path parameters
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Rotates ONE app's refresh chain. The chain is keyed (tenant_id, session_id, oauth_client_id), so rotating here cannot invalidate another app open under the same session — that per-chain isolation is what lets two products stay open in two tabs without either signing the other out. Reuse detection nonetheless keeps its strict policy: a genuinely replayed token revokes the SESSION, every app. Per-chain isolation removed the false positives, not the policy. Because the client is named in the PATH, CORS here is genuinely per-client — an origin belonging to a DIFFERENT registered client is refused exactly as an unregistered one is. Retires POST /v1/auth/refresh wherever MULTI_APP_ENABLED is on (that route answers 404 there). Behind MULTI_APP_ENABLED.

clientIdpathstringrequiredfrom a response
The client whose chain to rotate — the clientId from GET /v1/me/apps, and the same one that redeemed at POST /v1/oauth/token.
refreshTokenbodystringconditionalfrom a response
The current refresh credential — required for a 'body' client ONLY. Exactly one channel per client, decided by the REGISTRY and never selectable by the caller: a 'cookie' client's credential arrives automatically in rt_<client_id> (HttpOnly; Secure; SameSite=Strict; Path=/v1/auth/apps/<client_id>/refresh) and the body stays empty; a 'body' client's arrives here and is re-issued in the body of every rotation. A token belonging to a different chain is refused even when it arrives on the right-looking channel. 401 for missing/expired/replayed/wrong-chain; 403 when the grant or enablement was revoked mid-session.
POST https://api.kerja.team/v1/auth/apps/web-it-cms/refresh
↩︎Response guide — what comes back & what each value means5 fields

200 OK with a fresh access token for THIS app and a rotated refresh credential on the same registered channel. Cache-Control: no-store. The chain is keyed (tenant_id, session_id, oauth_client_id), so this rotation cannot invalidate another app open under the same session — but reuse detection stays session-wide: a genuinely replayed token revokes the SESSION, every app. Two tabs of the SAME app share one chain and are made safe by the SELECT … FOR UPDATE row lock on rotation, not by the per-app split. Errors: 401 for missing/expired/replayed/wrong-chain, 403 when the grant or enablement was revoked mid-session, 404 when the flag is off.

dataobjectalways
The rotated token payload for this app.
data.accessTokenstring (JWT)always
A fresh app-scoped access token (azp = this client). Auto-captured by this tester as the active bearer.
data.refreshTokenstringconditional
The rotated credential — 'body' clients only, re-issued in the body of every rotation. Absent for a 'cookie' client, which receives it as Set-Cookie instead and MUST re-store it each time.
data.expiresIninteger (seconds)conditional
Lifetime of the new access token.
Set-Cookie rt_<client_id>headerconditional
The rotated refresh cookie for a 'cookie' client, re-set on every rotation with the same HttpOnly; Secure; SameSite=Strict; Path=/v1/auth/apps/<client_id>/refresh scoping.