Recognising a returning browser
Signing inBefore any credential is typed, the login screen asks whether THIS browser has signed in here before, so it can render “Welcome back, Budi” instead of a blank form. Behind DEVICE_TRUST_ENABLED; always { known: false } when off.
- GET /v1/auth/device → { known } (+ displayName, maskedIdentifier, loginMode, and the usable-credential set methods with maskedEmail / maskedPhone when recognised)
- POST /v1/auth/login → render the form the answer implies and continue at scenario 9 / 10 — on a recognised device the body needs only { password }
/v1/auth/devicepublicIs this browser recognised?Reads the HttpOnly device_token cookie SERVER-SIDE — the cookie value never reaches script (HttpOnly; Secure; SameSite=Lax; Path=/v1/auth), and this endpoint is the only place its meaning is exposed, as a MASKED identifier and never a working one. SameSite=Lax (not Strict like the refresh cookie) so a top-level navigation from an emailed or WhatsApp link still presents it. A recognised device also returns methods — the account's USABLE credential set, a channel listed only when its credential is verified — plus a maskedEmail / maskedPhone per method, so the screen opens on a method the user can actually complete and keeps a mask on screen across an email↔phone switch. THE MASK STAYS A MASK: the client never needs the real value, because POST /v1/auth/login resolves the account from the same cookie and makes identifier optional. OFFER A WAY OUT — known: true is a hint, not a claim about who is sitting there, so the screen must carry a “Not you? / Sign in as someone else” affordance or a shared machine strands the second person. No oracle: no cookie, an expired one and a revoked one all return the identical { known: false }. known: true is NOT authentication and grants nothing — a device token identifies a device, never a user. Cache-Control: no-store, throttled per IP. The browser sends the cookie automatically once a login has set it (scenario 11).
Field guide — what each value means & where it comes from1 field
Public and unauthenticated — and it takes no inputs at all. The one value it reads, the device_token cookie, is HttpOnly and travels automatically: the browser attaches it because this tester sends credentials: "include" and the cookie's Path is /v1/auth. You cannot type it, read it from script, or forge it here; it is set only by completing a new-device login challenge (scenario 11). Cache-Control: no-store, throttled per IP.
device_tokenheadercookieoptionalauto-capturedGET https://api.kerja.team/v1/auth/deviceResponse guide — what comes back & what each value means7 fields
Always 200 with the { data } envelope; Cache-Control: no-store. NO ORACLE: no cookie, an expired cookie, a revoked one, and DEVICE_TRUST_ENABLED=false all produce the identical { known: false }. When the device IS recognised the extra fields are hints only — a display name and a MASKED identifier, never a working one, so a stolen laptop does not hand over a usable address.
data.knownbooleanalwaystrue— Render “Welcome back” with the hints below and the channel-appropriate form. This is NOT authentication and grants nothing — a device token identifies a DEVICE, never a user, and is only ever accepted alongside a verified password.false— Render the generic sign-in form. Says nothing about why: no cookie, expired, revoked, and feature-off are indistinguishable.
data.displayNamestringconditionaldata.maskedIdentifierstringconditionaldata.loginModeenumconditionalemail— Pre-fill the email form; a new-device challenge for this account would arrive as an email code.phone— Pre-fill the phone form; a new-device challenge would arrive as a phone OTP.
data.methods[]arrayconditionalemail— The account holds a VERIFIED email — offer the email form and switch to it with maskedEmail on screen.phone— The account holds a VERIFIED phone — offer the phone form (password or one-time code) with maskedPhone on screen.
data.maskedEmailstringconditionaldata.maskedPhonestringconditional/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