Entering a partner app (cross-app login)
Signing inA relying app (e.g. web-it-cms) sends the user to IAM to sign in and wants them bounced back afterwards. IAM validates the return URL against the registered client BEFORE rendering anything, which is where the open-redirect hole is closed. Behind OAUTH_ENABLED — the route 404s when off. THIS IS MODEL A, AND SCENARIO 16 SUPERSEDES IT: here the app asks IAM to validate a URL and IAM renders the login; there, the app sends a standards OAuth 2.1 authorization request and receives its OWN app-scoped tokens rather than riding the shared session. The two are gated by different flags (OAUTH_ENABLED here, MULTI_APP_ENABLED there) and can be rolled out independently, but an app that has moved to Model B has no use for this probe.
- GET /v1/oauth/clients/{clientId}/validate-return?return_to=… → always 200: { valid: true, displayName } on an exact match, else { valid: false }
- Only if valid — render the sign-in form and authenticate normally (any of scenarios 9–14)
- On success bounce to the VALIDATED return_to; on valid:false fall back to a safe default and never echo the supplied URL
/v1/oauth/clients/{clientId}/validate-returnpublicValidate a return_to URLALWAYS 200 — an unknown client and an unregistered URI return the identical { valid: false } with no reason, so the client registry cannot be enumerated. Matching is exact normalized-string equality (never startsWith, a regex, or a same-origin test): https only, except http for localhost / 127.0.0.1, and URLs carrying a fragment are rejected outright. Rejections append an oauth_redirect_rejected security event and the return_to value itself is NEVER recorded. Clients and their URIs are operator-registered configuration — there is no self-service registration endpoint. 404 when OAUTH_ENABLED is false.
Field guide — what each value means & where it comes from2 fields
Public and unauthenticated (no gateway context). The login UI asks whether a return_to URL is a REGISTERED redirect URI for a client before bouncing the user there — this is where the open-redirect hole is closed. Behind the OAUTH_ENABLED kill-switch: the route 404s when it is off. Cache-Control: no-store, throttled per IP.
clientIdpathstringrequiredyou choosereturn_toquerystringrequiredyou chooseGET https://api.kerja.team/v1/oauth/clients/web-it-cms/validate-return?return_to=http%3A%2F%2Flocalhost%3A3000%2Fauth%2FcallbackResponse guide — what comes back & what each value means2 fields
ALWAYS 200 — an unknown client and an unregistered URI return the IDENTICAL body with no reason, so the client registry cannot be enumerated. Cache-Control: no-store. (When OAUTH_ENABLED is false the route is not mounted at all and answers 404.)
data.validbooleanalwaystrue— Safe to bounce the user to the supplied return_to after they authenticate. Only ever true on an exact normalized-string match.false— Fall back to a safe default and NEVER echo the supplied URL. The same body is returned for an unknown client, an unregistered URI, an http (non-localhost) URL, and one carrying a fragment. A rejection appends an oauth_redirect_rejected security event, and the return_to value itself is never recorded.
data.displayNamestringconditional/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