Hosted UI
authorization_code + PKCE (OAuth 2.1 Flow, step 2)
sends the browser to QAuth itself for a stretch. Three server-rendered pages
handle that stretch — /ui/login, /ui/consent, and /ui/resume/{handle} — and
every integrator meets all three, even one that never renders its own login UI.
/ui/login
Section titled “/ui/login”GET /ui/login?return_to=<path>&error=<message> renders an email/password
form. return_to must be a relative, same-origin path — isSafeReturnTo
(apps/auth-server/src/app/helpers/return-to.ts:50) rejects absolute URLs,
//host protocol-relative values, and the /\host backslash variant a
browser also resolves as protocol-relative, falling back to / on anything
that fails. The page carries a signed, double-submit login-CSRF token in both
a hidden form field and a cookie.
POST /ui/login verifies the CSRF pair, then the credentials. On success it
mints a fresh session id (session-fixation defence — a pre-login session
id is never reused), sets it as __Host-qauth_session, and redirects to
return_to. On failure it re-renders the form, and the two failure modes
handle the CSRF token differently: a 403 (CSRF pair missing or mismatched,
apps/auth-server/src/app/routes/ui/login.ts:289) mints and sets a fresh
token so a legitimate retry can succeed; a 401 (bad credentials,
apps/auth-server/src/app/routes/ui/login.ts:341) reuses the existing
cookie’s token unchanged, since that cookie is still valid — only the
credentials were wrong.
/ui/consent
Section titled “/ui/consent”GET /ui/consent takes the same query parameters as
/oauth/authorize and renders the scope
consent screen. It requires a valid __Host-qauth_session cookie; without one
it redirects to /ui/login (via the pending-authorization mechanism below,
not a raw inline redirect). POST /ui/consent submits the decision
(allow / deny, CSRF-protected) and, on allow, issues the authorization
code and redirects to the client’s redirect_uri exactly as a first visit
would.
/ui/resume/{handle}
Section titled “/ui/resume/{handle}”GET /ui/resume/{handle} is where a login round-trip lands after a
successful sign-in. It exchanges a single-use handle for the pending
/oauth/authorize URL and redirects there — see below for what the handle
is and why it exists. Resuming re-validates client_id, redirect_uri,
scope, PKCE, and step-up from scratch, exactly as on a first visit
(apps/auth-server/src/app/routes/ui/resume.ts:124); a resumed handle grants
nothing a fresh authorize request would not.
Pending authorization: why the login bounce carries a handle, not a URL
Section titled “Pending authorization: why the login bounce carries a handle, not a URL”Before qauth-labs/qauth#319, every surface that bounced an unauthenticated
user to /ui/login nested the entire authorize query string inside
return_to — /ui/login?return_to=%2Foauth%2Fauthorize%3F.... Once state
and nonce could legally reach 2048 characters each
(OAUTH_OPAQUE_PARAM_MAX_LENGTH,
apps/auth-server/src/app/constants/security.ts:71), that Location header
could exceed ~10 KB — past a typical reverse proxy’s default header-buffer
limit, so the browser got a 414/400 instead of the login page, on the
unauthenticated first-visit path, and invisibly so in local dev (no proxy
in front).
#319 fixed this by parking the authorize URL server-side instead of nesting it in the redirect:
-
An unauthenticated
GET /oauth/authorizeno longer nests the query. It stashes the normalized authorize URL in Redis under a fresh, 43-character CSPRNG handle (randomBytes(32).toString('base64url'),apps/auth-server/src/app/helpers/pending-authorization.ts:191) and redirects to/ui/login?return_to=%2Fui%2Fresume%2F<handle>— a ~60-byte path regardless of how largestate,nonce, orresourceare. The same stash-then-redirect helper (redirectToLoginWithPendingAuthorization,apps/auth-server/src/app/helpers/pending-authorization.ts:283) backs all five login-bounce call sites: two inapps/auth-server/src/app/routes/oauth/authorize.ts(lines 334 and 554) and three inapps/auth-server/src/app/routes/ui/consent.ts(lines 369, 559, and 763) — #319 closed #316 specifically because an earlier fix had patched only one of these sites and missed the rest. -
Single-use.
consumePendingAuthorization(apps/auth-server/src/app/helpers/pending-authorization.ts:235) deletes the Redis record as it reads it, before even validating its contents. A login URL saved from browser history or a screen recording therefore cannot be replayed — the secondGET /ui/resume/{handle}for the same handle gets the “expired” page, not a second redirect. -
Expires after 10 minutes.
PENDING_AUTHORIZATION_TTL_MS(apps/auth-server/src/app/constants/security.ts:101) bounds the stash to 10 minutes — long enough to cover an interactive login (reading the page, a password manager, typing credentials) but short because this is auth-flow state, not user session state. An expired, unknown, or already-used handle all render the same “this sign-in request has expired” page (expiredPage,apps/auth-server/src/app/routes/ui/resume.ts:40) with a plain400and no redirect — the three cases are made indistinguishable on purpose, so a handle can’t be probed to learn which ones ever existed. -
A
stateover 16 KB is rejected outright.PENDING_AUTHORIZATION_MAX_URL_BYTES(apps/auth-server/src/app/constants/security.ts:131) caps the authorize URL the stash will accept. This is a backstop behind the per-parameter 2048-character bounds already enforced at the edge, not the primary control — but because the stash is written before the caller authenticates, an unbounded write there would be a pre-auth memory-pressure primitive against the same Redis that holds live browser sessions and rate-limit counters. Exceeding it returns400 invalid_request: authorization request is too large(apps/auth-server/src/app/helpers/pending-authorization.ts:215) rather than either a500or aLocationheader no proxy would forward.
What the stash adds to the pre-authentication path
Section titled “What the stash adds to the pre-authentication path”Redis was already on that path before #319, and not marginally: the
Redis-backed @fastify/rate-limit plugin is registered app-wide
(apps/auth-server/src/app/app.ts:255) ahead of both route sweeps (lines 294
and 301), against the same fastify.redis client
(apps/auth-server/src/app/plugins/rate-limit.ts:27); RATE_LIMIT_ENABLED
defaults to true (libs/server/config/src/lib/schemas/rate-limit.ts:12-15);
and the onRequest hook it attaches to each of those routes INCRs a per-IP
counter on every request, authenticated or not (PEXPIRE on the first
request of a window, PTTL on the rest) — which is why the size cap above talks
about “the same Redis that holds live browser sessions and rate-limit
counters”. What the pending-authorization stash adds is narrower and worth
naming precisely: it is
the first caller-influenced write an unauthenticated visitor can trigger,
which is the reason PENDING_AUTHORIZATION_MAX_URL_BYTES exists at all.
The stash itself degrades rather than fails. If the write cannot complete,
redirectToLoginWithPendingAuthorization catches the failure and falls back
to the pre-#319 inline return_to
(apps/auth-server/src/app/helpers/pending-authorization.ts:299). That
inline fallback is correct for every request small enough to survive a
proxy’s header buffer — only the pathological multi-kilobyte state that
motivated the stash would still be too large, and for that one case an
oversized redirect is judged better than an opaque 500 at the
authorization endpoint. Symmetrically, a read failure in
consumePendingAuthorization is treated as a miss and renders the same
“expired” page a genuinely-expired handle would
(apps/auth-server/src/app/helpers/pending-authorization.ts:245) — an
unreachable Redis is, from the user’s side of /ui/resume/{handle},
indistinguishable from a handle that timed out.
Do not read those fallbacks as “a Redis outage is invisible here”, though.
@fastify/rate-limit’s skipOnError is left at its default of false, so
when the store is unreachable the rate-limiter rethrows from its onRequest
hook — before the route handler, and therefore before either fallback can
run. The fallbacks cover a stash operation that fails on its own; they do not
cover an instance-wide Redis outage, which fails the request at the
rate-limit hook instead.
Not an open redirector
Section titled “Not an open redirector”The handle is never resolved from anything the client controls at resume
time — it comes back out of Redis and is re-validated
(normalizeInternalAuthorizeUrl,
apps/auth-server/src/app/helpers/pending-authorization.ts:153) to be
this server’s own /oauth/authorize path before use, both on the way in and
the way out. The only reachable destination is a fresh visit to
/oauth/authorize, which then re-checks client_id and redirect_uri
exactly as it would on a first request.
See also
Section titled “See also”- OAuth 2.1 Flow — the
authorization_code+ PKCE walkthrough these screens sit inside. - API Reference → Hosted UI — the wire contract (methods, parameters, status codes) for these three routes.