Authentication API · auth_bff

The login, gobj by gobj

The whole exchange, running: which gobj sends which event to which gobj, what the browser holds, and what a new GUI has to implement. The token never crosses into JavaScript — that one restriction explains everything else.

browser yuneta node external

        

FSM state

Browser cookies

access_token
refresh_token

HttpOnly · Secure · SameSite=Strict

What JavaScript sees


        

The division of labour

Almost all the weight sits in the backend, and it is already written. What is left for the GUI is small — but getting one of those pieces wrong breaks the session in ways that are hard to diagnose.

frontend

What you implement

  • Send every request with credentials: "include". Without it cookies do not travel and nothing works.
  • Classify each failure: transport or verdict. This is the decision that breaks the most sessions.
  • Arm the refresh before the access_token expires, using the expires_in you were given.
  • Try to restore the session on page load, before painting the form.
  • Model the states in an FSM and send every action as an event.
  • Translate by error_code; never display the error field.
backend

What the BFF already does

  • Speak OAuth2 to the IdP: it is the only thing in the system that does.
  • Discover the endpoints from issuer, and retry when that fails.
  • Write and clear the cookies with their security attributes.
  • Bound every IdP round-trip with a watchdog, and answer instead of hanging.
  • Validate the JWT of every incoming WebSocket against the issuer's JWKS.
  • Return a stable error_code, designed as an i18n key.

The decision that breaks sessions

A failure of the transport says nothing about the user's credentials. The network going away, the backend answering 5xx, a body that is not JSON — a proxy error page — none of that is a denial: it is noise. The session is still alive, because the refresh cookie is untouched.

Rule: only a 4xx from the BFF is a verdict on the user. Everything else is retried with backoff, and the session is kept.

Reading data.success while ignoring the HTTP status is the classic mistake: after a node reboots, the 502 it answers while coming up reads as "credentials rejected" and drops the user to the login form for no reason.

The four endpoints

All POST. All need credentials: "include".

EndpointBody you sendWhat it doesCookies
/auth/login{username, password}Exchanges credentials with the IdPwrites both
/auth/callback{code, code_verifier,
 redirect_uri}
PKCE code exchangewrites both
/auth/refreshRenews from the refresh cookierewrites both
/auth/logoutEnds the session at the IdP tooclears both

Responses

Success always has this shape. The tokens are absent: they are in the cookies.

{ "success": true,
  "username": "…",
  "email": "…",
  "expires_in": 300,
  "refresh_expires_in": 1800 }

And an error, always this one:

{ "success": false,
  "error_code": "session_expired",
  "error":      "…"   ← for the log, NOT for the user }

The error_code catalogue

It is the stable contract with the GUI: use it as the translation key. The right-hand column is how your client must react.

error_codeHTTPWhenReact as
invalid_credentials401Wrong username or password (login only)verdict
session_expired401Refresh token or auth code no longer validverdict
account_disabled403Account disabled or not fully set upverdict
auth_rate_limited429Too many login attemptsverdict
missing_refresh_token401No refresh cookie presentverdict
missing_access_token401/auth/token without the AT cookieverdict
method_not_allowed405Request was not POSTverdict
missing_body400POST with no JSON bodyverdict
missing_params400Required fields absentverdict
redirect_uri_not_allowed400redirect_uri fails the allowlistverdict
origin_not_allowed403Origin does not match (fail-closed)verdict
unknown_endpoint404URL outside the setverdict
auth_config_error500BFF misconfiguredtransport
auth_service_unavailable502IdP unreachable or 5xxtransport
auth_unexpected_error502IdP returned something unexpectedtransport
auth_empty_response502IdP reply had no bodytransport
server_busy503Pending queue fulltransport
auth_timeout504Watchdog fired, no reply in timetransport

The minimal FSM

Three states. Every action enters as an event — a click included. A view that lives entirely in one state, calling functions straight from DOM callbacks, throws away the machine trace, which is the only thing that explains a failure afterwards.

StateEvents it acceptsMeans
ST_LOGOUTEV_DO_LOGIN · EV_LOGIN_DENIED · EV_LOGOUT_DONENo session. The form is on screen.
ST_WAIT_TOKENEV_LOGIN_ACCEPTED · EV_LOGIN_DENIEDA request is in flight: login, or restore on load.
ST_LOGINEV_TIMEOUT · EV_LOGIN_REFRESHED · EV_REFRESH_FAILED · EV_DO_LOGOUTSession alive, refresh armed.

The trap when adding EV_REFRESH_FAILED: if you publish it as an output event, every subscriber of the login service must declare it in its FSM. One that does not will raise "Event NOT DEFINED in state" on every transient failure — you will have traded an annoying logout for a noisy error. If a subscriber is frozen and you cannot touch it, do not publish: report it inside the service instead.

Checklist for a new GUI

  1. Create the login service with the BFF base URL and a subscriber.
  2. Restore on start: POST /auth/refresh before painting anything. If it succeeds you are in; if not, show the form. On a cold load do not build a retry loop: there is no session to protect.
  3. Login: the form submit sends an event; the action performs the POST.
  4. Classify the response by HTTP status before looking at success.
  5. Arm the refresh from expires_in, a few seconds early.
  6. Back off on a transient failure, starting at seconds with a cap; reset it when a refresh lands.
  7. Logout: POST /auth/logout and return to ST_LOGOUT. Do not clear cookies from JS — you cannot.
  8. Translate by error_code and add the codes to the locale catalogues. A missing key renders as lowercase English and never changes language.