Auth Pages
When a project selects both the web frontend and the auth service, it gets working auth pages wired to the service's existing endpoints. Without the auth service, none of this is generated: no auth routes, no auth templates, no auth JavaScript.
The pages are a UI over the auth service, not a second implementation of it. Sessions, tokens, cookies, lockout, and rate limiting are all owned by the service; the pages submit to it and render the results.
What ships
| Page | Purpose |
|---|---|
/login |
Sign in. Redirects to ?next= on success. |
/register |
Create an account; lands on the verify-pending page. |
/logout |
Drops the session and lands on /. |
/forgot-password |
Requests a reset email. |
/reset-password |
Consumes the emailed reset token. |
/verify-email |
Consumes the emailed verification token. |
/verify-pending |
"Check your inbox", with a resend button. |
All of them extend a shared split-screen shell
(templates/pages/auth/_layout.html) and use the form macros from
templates/components/auth_macros.html. The shell marks these pages
noindex, nofollow: reset and verification links carry one-time tokens in
their URLs, and crawlers must not snapshot them.
Two deliberate design points
Login and register are native form POSTs
The login and register forms submit as real browser form POSTs, not
fetch() calls. This is what makes the browser's save-password prompt work:
the browser needs to see a genuine password-field submit followed by a
redirect. A JavaScript submit handler that calls fetch() produces an
identical-looking flow and silently kills the prompt.
The page handlers delegate to the auth service's own login and register endpoints rather than reimplementing them, so lockout, failed-attempt tracking, audit events, and rate limits apply identically whether a user signs in through the page or through the API.
No save prompt on plain http://localhost
Chrome refuses to offer password saving on plain-HTTP localhost. That is a browser rule, not a bug in the pages. It works over HTTPS and on real hostnames.
Session-expiry handling is single-flight
static/js/auth.js provides fetchAuth(), a fetch() wrapper that retries
once through the auth service's refresh endpoint on a 401, and a global htmx
hook that does the same for hx- requests.
The refresh is single-flight: one shared in-flight promise, no matter how many requests hit a 401 at the same moment. This matters because the auth service rotates refresh tokens with reuse detection. A page that fires several authenticated requests in parallel would otherwise trigger several concurrent refresh calls, and the second one would replay the token the first had just rotated, which the service treats as theft and revokes the whole session.
Protecting a page
Page protection is opt-in, mirroring how API endpoints are protected: a page without a guard is public. To require a signed-in user, take the optional-user dependency and bounce through the helper:
from app.services.auth.deps import get_optional_user
@router.get("/settings", response_class=HTMLResponse, include_in_schema=False)
async def settings_page(
request: Request,
user: User | None = Depends(get_optional_user),
) -> HTMLResponse:
bounce = _current_user_or_redirect(request, user)
if bounce:
return bounce
return templates.TemplateResponse(
request=request, name="pages/settings.html", context={"user": user}
)
Anonymous visitors are redirected to /login?next=/settings and returned
after signing in. The next value is validated server-side: only local paths
are followed, so the login page cannot be used as an open redirect.
There is deliberately no default-deny middleware. One protection mechanism, attached where you want it, matching the rest of the stack.