Running several apps at once
Sessions & devicesMULTI_APP_ENABLEDScenario 18 renews THE session. Under MULTI_APP_ENABLED there is still one session, but N renewal chains — one per app — so two products stay open in two tabs and neither can sign the other out. Before enhancement 5 this was not merely unsupported but punished: both apps presented the same cookie, the second tripped reuse detection, and the whole session died.
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.
- POST /v1/oauth/token (app A) → A redeems its code and receives its own access token (azp = A) and its own refresh credential
- POST /v1/oauth/token (app B) → a SECOND chain opens under the SAME session; A's stays live and untouched
- POST /v1/auth/apps/{clientId}/refresh → each app renews on its own route, keyed (tenant, session, client). A's rotation cannot invalidate B's
- GET /v1/me/apps → optional; the drop-down marks the caller's current app via isCurrent, matched on the azp the gateway stamped
- THE CREDENTIAL'S CHANNEL IS A REGISTRY PROPERTY, NEVER THE CALLER'S CHOICE · cookie (the default) — the app is same-site with the IAM API origin; delivered as Set-Cookie rt_<client_id> (HttpOnly; Secure; SameSite=Strict; Path=/v1/auth/apps/<client_id>/refresh); unreachable by page JS; TTL REFRESH_TOKEN_TTL_SECONDS
- … · body — the app sits on an UNRELATED registrable domain; delivered in the JSON body of the exchange AND of every rotation; reachable by page JS (that is the whole cost); TTL CROSS_SITE_REFRESH_TOKEN_TTL_SECONDS (8h) — shorter, because it cannot be bounded by HttpOnly
/v1/oauth/tokenpublicApp A redeems its codeGet a code for app A from scenario 16 (authorize) or 17 (hand-off) first — each redemption needs the verifier that matches the challenge THAT request carried. A receives its own access token (azp = A's client) and its own refresh credential. 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.
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/oauth/tokenpublicApp B redeems its code (second chain, same session)Entering a second app is NOT a second authentication: no session is created, auth_time is carried forward, and no device challenge (scenario 11) is reachable from this path — otherwise the step-up clock would restart every time a user changed tabs. A second chain opens under the SAME session and A's stays live. Re-entering an app RETIRES its incumbent chain rather than opening a second: exactly one live chain per (session, client) is permitted, and the holder of the old credential is the same browser that was just handed a fresh one. CORS granularity differs by what a preflight can identify: this endpoint carries the client id in the BODY, so its allow-list is registry-wide across body clients and the per-client binding is enforced at the endpoint instead — by the code binding and PKCE, which no origin can influence. Contrast the CODE replay rule here (any second use is 400 invalid_grant and the code stays spent, but only a replay with ALL bindings valid also revokes the chain) with the session-wide reuse rule on the rotation route.
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. A COOKIE CLIENT MUST RE-STORE THE ROTATED COOKIE EVERY TIME — the server re-sets it on each rotation, and a client that falls behind the chain reads as a replay on its next renewal. BODY is strictly weaker and is a decision with a named owner: an XSS there steals a credential that outlives the tab, so it is chosen only where both alternatives are worse — a third-party cookie browsers are removing, or a full-page redirect every ten minutes. CORS: this route names the client in the PATH, so its allow-list is genuinely per-client — an origin belonging to a DIFFERENT registered client is refused exactly as an unregistered one is.
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/v1/me/appsauthList my apps (the drop-down)Apps the caller holds in the SESSION'S ACTIVE TENANT only — an app held in another tenant is absent, and reaching it means an explicit tenant switch first (scenario 20), which moves every open tab and is therefore a visible act rather than a side effect of picking from a list. Filtered to live product apps with an active enablement: the identity app is the shell that renders this list, and a suspended enablement would offer a door that refuses to open. Each row carries the clientId, because apps are not addressable — clients are. isCurrent is matched on the azp the gateway stamped. This list comes from the API, never from x-gateway-context, which carries only the current app as the single scalar azp.
Field guide — what each value means & where it comes fromno inputs
The app drop-down: apps the caller holds in the SESSION'S ACTIVE TENANT ONLY. No inputs — the session supplies both the user and the tenant. Filtered to live product apps with an active enablement: the identity app is the shell that renders this list rather than an entry in it, and a suspended enablement would offer a door that refuses to open. An app held only in ANOTHER tenant is absent — reaching it means an explicit tenant switch first, which moves every open tab and is therefore a visible act rather than a side effect of picking from a list. Needs no admin role: every member sees their own apps.
GET https://api.kerja.team/v1/me/appsResponse guide — what comes back & what each value means6 fields
200 OK with { data: { items: [...] } } — the app drop-down, scoped to the SESSION'S ACTIVE TENANT. Filtered to live product apps with an active enablement, so the identity app never appears (it is the shell rendering this list) and a suspended app is absent rather than offered as a door that refuses to open. An app held only in another tenant is absent too — reaching it means an explicit tenant switch, which moves every open tab. This list comes from the API, NOT from x-gateway-context: that envelope carries only the current app as the single scalar azp, which is what makes per-route enforcement possible at the edge.
data.itemsarrayalwaysdata.items[].codestringalwaysdata.items[].labelstringalwaysdata.items[].clientIdstringalwaysdata.items[].sortOrderintegeralwaysdata.items[].isCurrentbooleanalwaystrue— The app this request came from — render it as the current selection rather than a destination.false— A different app; picking it starts a hand-off (POST /v1/oauth/handoff).