Switching apps from the drop-down (hand-off)
Signing inMULTI_APP_ENABLEDThe 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.
- 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
- POST /v1/oauth/handoff → { targetClientId, codeChallenge } answers { code, redirectUri }. The session comes from the gateway context, NEVER from the body
- One top-level browser navigation to the returned redirectUri — the registry supplies it, so the body cannot steer it into an open redirect
- POST /v1/oauth/token → the target app redeems the code with its verifier; the same redemption path as scenario 16
/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).
/v1/oauth/handoffauthMint a hand-off code for the target appThe 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.
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 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 responsecodeChallengebodystringrequiredclient-generatedPOST https://api.kerja.team/v1/oauth/handoffResponse 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.
dataobjectalwaysdata.codestringalwaysdata.redirectUristring (URL)always/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