Signing in through an app's authorization request (OAuth 2.1)
Signing inMULTI_APP_ENABLEDThe 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.
- 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
- 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
- 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
- POST /v1/auth/apps/{clientId}/refresh — ongoing, rotates THAT app's chain only. /v1/auth/refresh is retired wherever MULTI_APP_ENABLED is on
/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.
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.
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 responseredirect_uriquerystring (URL)requiredfrom a responseresponse_typequeryenumrequiredyou choosecode— The authorization-code flow — the only accepted value. The code is redeemed at POST /v1/oauth/token.
code_challengequerystringrequiredclient-generatedcode_challenge_methodqueryenumrequiredyou chooseS256— SHA-256. The only accepted value.plain— Refused at the validation gate.
statequerystringoptionalclient-generatedGET 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=xyzResponse 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)alwaysLocation ?codestringconditionalLocation ?statestringconditionalLocation ?issstringconditionalLocation ?errorenumconditionalaccess_denied— The 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/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).
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 chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse 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.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_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.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_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.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional/v1/oauth/tokenpublicRedeem the code for this app's tokensONE 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.
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 chooseauthorization_code— Redeem a code minted by /v1/oauth/authorize or /v1/oauth/handoff. The only accepted value here.
client_idbodystringrequiredfrom a responsecodebodystringrequiredfrom a responsecode_verifierbodystringrequiredclient-generatedredirect_uribodystring (URL)requiredyou choosePOST https://api.kerja.team/v1/oauth/tokenResponse 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.
dataobjectalwaysdata.accessTokenstring (JWT)alwaysdata.refreshTokenstringconditionaldata.expiresIninteger (seconds)conditionaldata.tokenTypestringconditionalSet-Cookie rt_<client_id>headerconditional/v1/auth/apps/{clientId}/refreshpublicRotate THIS app's refresh chainThe 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.
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 responserefreshTokenbodystringconditionalfrom a responsePOST https://api.kerja.team/v1/auth/apps/web-it-cms/refreshResponse 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.
dataobjectalwaysdata.accessTokenstring (JWT)alwaysdata.refreshTokenstringconditionaldata.expiresIninteger (seconds)conditionalSet-Cookie rt_<client_id>headerconditional