switching clears the token & responses
No token
17

Switching apps from the drop-down (hand-off)

Signing inMULTI_APP_ENABLED

The in-product counterpart of scenario 16: an already-signed-in user picks another app from the drop-down and lands in it signed in, without a round trip through a login screen.

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/me/apps → the drop-down: apps the caller holds in the session's ACTIVE TENANT, each with the clientId needed to address it
  2. POST /v1/oauth/handoff → { targetClientId, codeChallenge } answers { code, redirectUri }. The session comes from the gateway context, NEVER from the body
  3. One top-level browser navigation to the returned redirectUri — the registry supplies it, so the body cannot steer it into an open redirect
  4. POST /v1/oauth/token → the target app redeems the code with its verifier; the same redemption path as scenario 16
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).
POST/v1/oauth/handoffauthMint a hand-off code for the target app

The session comes from the VERIFIED GATEWAY CONTEXT, never from the body (AUTH-IAM-03) — a body-supplied session id would let any authenticated caller mint a code against somebody else's session. The target app must be granted to this member in this tenant: a hand-off to an app the caller does not hold is 403, audited as app_grant_denied, because the drop-down is a convenience and not the authorization. redirectUri comes from the registry (the lowest of that client's registered URIs by string order), so the body cannot steer it into an open redirect. Press ⟳ Generate to mint the challenge — the verifier is carried to the token card below. Revoking a grant (scenario 49) takes effect at the member's NEXT REQUEST, not their next login, because the grant is re-checked live at every /internal/validate hop.

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

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

Mints a hand-off code so an already-signed-in user can open another app from the drop-down without a round trip through a login screen. The SESSION COMES FROM THE VERIFIED GATEWAY CONTEXT, NEVER FROM THE BODY (AUTH-IAM-03) — a body-supplied session id would let any authenticated caller mint a code against somebody else's session. The target app must be granted to this member in this tenant; a hand-off to an app the caller does not hold is 403, audited as app_grant_denied — the drop-down is a convenience, not the authorization. Behind MULTI_APP_ENABLED.

targetClientIdbodystringrequiredfrom a response
The client to hand off to — the clientId from GET /v1/me/apps. Apps are not addressable; clients are, which is why the drop-down carries a clientId and not just a code.
codeChallengebodystringrequiredclient-generated
The PKCE challenge, BASE64URL(SHA-256(code_verifier)), same construction as /v1/oauth/authorize. Keep the verifier — the target app redeems with it at POST /v1/oauth/token.
Bearer token
POST https://api.kerja.team/v1/oauth/handoff
↩︎Response guide — what comes back & what each value means3 fields

200 OK with { code, redirectUri }. Cache-Control: no-store. The browser makes ONE top-level navigation to redirectUri and the target app redeems the code at POST /v1/oauth/token — the same redemption path as the authorize route, differing only in issued_via. A hand-off to an app the caller does not hold is 403, audited as app_grant_denied.

dataobjectalways
The hand-off payload.
data.codestringalways
The authorization code for the target app, bound to the codeChallenge you sent. Same tiny TTL as an authorize-issued code.
data.redirectUristring (URL)always
Where to navigate. Comes from the REGISTRY — the lowest of that client's registered URIs by string order, a documented convention rather than a constraint — so the request body cannot steer it into an open redirect.
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.