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".
Endpoint
Body you send
What it does
Cookies
/auth/login
{username, password}
Exchanges credentials with the IdP
writes both
/auth/callback
{code, code_verifier, redirect_uri}
PKCE code exchange
writes both
/auth/refresh
—
Renews from the refresh cookie
rewrites both
/auth/logout
—
Ends the session at the IdP too
clears both
Responses
Success always has this shape. The tokens are absent: they are in the cookies.
{ "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_code
HTTP
When
React as
invalid_credentials
401
Wrong username or password (login only)
verdict
session_expired
401
Refresh token or auth code no longer valid
verdict
account_disabled
403
Account disabled or not fully set up
verdict
auth_rate_limited
429
Too many login attempts
verdict
missing_refresh_token
401
No refresh cookie present
verdict
missing_access_token
401
/auth/token without the AT cookie
verdict
method_not_allowed
405
Request was not POST
verdict
missing_body
400
POST with no JSON body
verdict
missing_params
400
Required fields absent
verdict
redirect_uri_not_allowed
400
redirect_uri fails the allowlist
verdict
origin_not_allowed
403
Origin does not match (fail-closed)
verdict
unknown_endpoint
404
URL outside the set
verdict
auth_config_error
500
BFF misconfigured
transport
auth_service_unavailable
502
IdP unreachable or 5xx
transport
auth_unexpected_error
502
IdP returned something unexpected
transport
auth_empty_response
502
IdP reply had no body
transport
server_busy
503
Pending queue full
transport
auth_timeout
504
Watchdog fired, no reply in time
transport
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.
State
Events it accepts
Means
ST_LOGOUT
EV_DO_LOGIN · EV_LOGIN_DENIED · EV_LOGOUT_DONE
No session. The form is on screen.
ST_WAIT_TOKEN
EV_LOGIN_ACCEPTED · EV_LOGIN_DENIED
A request is in flight: login, or restore on load.
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
Create the login service with the BFF base URL and a subscriber.
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.
Login: the form submit sends an event; the action performs the POST.
Classify the response by HTTP status before looking at success.
Arm the refresh from expires_in, a few seconds early.
Back off on a transient failure, starting at seconds with a cap; reset it when a refresh lands.
Logout:POST /auth/logout and return to ST_LOGOUT. Do not clear cookies from JS — you cannot.
Translate by error_code and add the codes to the locale catalogues. A missing key renders as lowercase English and never changes language.