switching clears the token & responses
No token
19

Running several apps at once

Sessions & devicesMULTI_APP_ENABLED

Scenario 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.

  1. POST /v1/oauth/token (app A) → A redeems its code and receives its own access token (azp = A) and its own refresh credential
  2. POST /v1/oauth/token (app B) → a SECOND chain opens under the SAME session; A's stays live and untouched
  3. POST /v1/auth/apps/{clientId}/refresh → each app renews on its own route, keyed (tenant, session, client). A's rotation cannot invalidate B's
  4. GET /v1/me/apps → optional; the drop-down marks the caller's current app via isCurrent, matched on the azp the gateway stamped
  5. 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
  6. … · 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
POST/v1/oauth/tokenpublicApp A redeems its code

Get 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.

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/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.

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. 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.

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.
GET/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.

Bearer token
GET https://api.kerja.team/v1/me/apps
↩︎Response 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.itemsarrayalways
Apps the caller holds in the active tenant. Empty when the member has been granted nothing.
data.items[].codestringalways
The app's registry code.
data.items[].labelstringalways
The display name to render in the drop-down.
data.items[].clientIdstringalways
The OAuth client to address this app by — pass it as targetClientId to POST /v1/oauth/handoff. Present because apps are not addressable, clients are: an entry without one is a link to nowhere.
data.items[].sortOrderintegeralways
The registry ordering hint, carried through so the drop-down orders the same way everywhere.
data.items[].isCurrentbooleanalways
Whether this is the app the caller is currently in, matched on the azp the gateway stamped.
  • trueThe app this request came from — render it as the current selection rather than a destination.
  • falseA different app; picking it starts a hand-off (POST /v1/oauth/handoff).