feat: add browser auth shell
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
# 0034: Dashboard browser authentication is host-owned and principal-first
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-23
|
||||
- Amends: [0011](0011-identity-http-transport-and-public-enrollment.md)
|
||||
- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[0027](0027-dashboard-navigation-and-flat-modules.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia already had email/password authentication, an HttpOnly session cookie, a minimal
|
||||
`GET /api/v1/principal` contract, authorization middleware, and a Dashboard shell. The shell also
|
||||
already resolved the principal before requesting protected navigation. What it did not have was a
|
||||
browser path for an anonymous person to become authenticated or for an authenticated person to end
|
||||
their session. An anonymous bootstrap stopped at a message telling the viewer to sign in, while no
|
||||
surface drove the existing authentication endpoints.
|
||||
|
||||
Closing that gap affects more than a form. A module UI may have an active API request or realtime
|
||||
subscription when a session expires or a person signs out. Better Auth's successful email sign-in
|
||||
response also includes a session token and user object in JSON even though the browser session is
|
||||
already represented by an HttpOnly cookie. A browser flow must not expose or adopt that redundant
|
||||
token, turn every authorization denial into logout, create an open-redirect input, or move platform
|
||||
authentication into a feature module.
|
||||
|
||||
The display host adds a second important boundary. It is permission-gated today, but unattended
|
||||
device identity is explicitly deferred by ADR 0033. Adding Dashboard sign-in must not silently make
|
||||
the display application a device enrollment flow or add Dashboard chrome to its bundle.
|
||||
|
||||
## Decision
|
||||
|
||||
### The host owns authentication UX; modules retain the host-neutral context
|
||||
|
||||
`surfaces/shell` owns the Dashboard's sign-in form, pending and failure states, sign-out control,
|
||||
session-transition behavior, and credential submission. A module never renders the platform login,
|
||||
imports an authentication client, receives a session token, or chooses how the host authenticates.
|
||||
|
||||
The public module UI contract remains exactly the five-member, host-neutral
|
||||
`ModuleUiHostContext`: `moduleId`, `api`, `principal`, `permissions`, and `realtime`. No sign-in,
|
||||
sign-out, credential, cookie, token, or Ekklesia-specific session callback is added to it. The host
|
||||
may observe authorization responses from the API client it supplies, but that observation remains
|
||||
host-private and the module still receives the original response.
|
||||
|
||||
This keeps one module artifact runnable under Ekklesia and a future standalone host. A standalone
|
||||
application will own its authentication experience just as the Ekklesia Dashboard owns this one;
|
||||
the module will continue to see only the same narrow principal and permission hints.
|
||||
|
||||
### Dashboard bootstrap remains principal-first
|
||||
|
||||
The Dashboard resolves `GET /api/v1/principal` before requesting navigation or rendering any module
|
||||
UI:
|
||||
|
||||
```text
|
||||
loading
|
||||
-> anonymous -> host-owned login
|
||||
-> authenticated -> protected Dashboard navigation -> selected module
|
||||
-> unavailable/invalid -> sanitized failure with explicit retry
|
||||
```
|
||||
|
||||
An anonymous principal is an ordinary, successful identity result, not an application failure. A
|
||||
principal infrastructure failure is not anonymous and does not reveal a login form that would
|
||||
misrepresent the outage as a missing session. No protected Dashboard request is made and no module
|
||||
code is loaded until the authenticated branch is established.
|
||||
|
||||
After a successful sign-in mutation, the host does not trust an authentication response as a
|
||||
principal. It resolves `/api/v1/principal` again and only then loads Dashboard navigation. This
|
||||
changes the rendered state in place; a full page reload is not required.
|
||||
|
||||
### Email/password uses the native endpoint behind two narrow browser-safe exceptions
|
||||
|
||||
The browser submits JSON credentials with `credentials: "include"` to Better Auth's existing
|
||||
native endpoint:
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-in/email
|
||||
```
|
||||
|
||||
ADR 0011 made normal Better Auth responses pass through the Core identity runtime unchanged. That
|
||||
remains true inside Core. This decision introduces exactly two browser-facing exceptions at the
|
||||
`apps/api` transport boundary:
|
||||
|
||||
1. When the exact email sign-in endpoint returns any body-bearing successful `2xx`, `apps/api`
|
||||
preserves that status and the headers — including `Set-Cookie` — but replaces the body with the
|
||||
fixed response below.
|
||||
2. The token-bearing native session inspection/mutation paths `/api/auth/get-session`,
|
||||
`/api/auth/list-sessions`, `/api/auth/update-session`, and `/api/auth/change-password` are
|
||||
unavailable. The API returns the same sanitized `404 {"code":"NOT_FOUND"}` as an unsupported
|
||||
route without invoking Better Auth. Password/account management is outside this pass; the pinned
|
||||
change-password route can return a new raw token when it revokes other sessions, so it cannot be
|
||||
left as an unused bypass.
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
```
|
||||
|
||||
Sign-in failures and every other supported Better Auth endpoint keep their existing response
|
||||
semantics. These exceptions are not a second authentication protocol: supported requests still
|
||||
reach Better Auth, Better Auth still creates and invalidates the session, and its HttpOnly cookie
|
||||
remains the only browser session authority. The normalization discards the redundant session token
|
||||
and user object before browser application JavaScript can read them. The denylist prevents a caller
|
||||
from bypassing the narrow Ekklesia identity contract to inspect or mutate native session objects.
|
||||
The supported browser identity read is exclusively `GET /api/v1/principal`; the shell treats
|
||||
`{ "ok": true }` only as permission to re-resolve it and does not persist, log, or derive identity
|
||||
from the body.
|
||||
|
||||
The form exposes only email and password. It is a semantic browser form with labelled inputs,
|
||||
`username` and `current-password` autocomplete hints, keyboard submission, one in-flight attempt,
|
||||
and a visible pending state. A completed attempt clears the password while retaining the email for
|
||||
correction. Credential rejection has one generic message; service and malformed-response failures
|
||||
have a separate fixed generic message. Raw response bodies, caught exceptions, credentials, and
|
||||
request URLs never reach the DOM or console.
|
||||
|
||||
### Sign-out invalidates first, then asks the principal authority
|
||||
|
||||
The host submits an empty JSON object with `credentials: "include"` to the existing endpoint:
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-out
|
||||
```
|
||||
|
||||
After the sign-out mutation succeeds, the host resolves `/api/v1/principal` instead of assuming
|
||||
that hiding the Dashboard equals logout. An anonymous result shows the login form. If the sign-out
|
||||
request throws or the subsequent principal resolution fails, protected UI stays removed and the
|
||||
host shows a sanitized failure with actions to retry sign-out or explicitly rebootstrap. It neither
|
||||
claims that the session ended nor automatically restores an authenticated state it has not
|
||||
revalidated.
|
||||
|
||||
While the transition is in progress, the authenticated Dashboard subtree is removed. The generic
|
||||
module loader therefore invokes the selected module's existing unmount cleanup, and the module's
|
||||
host-owned realtime subscriptions close through their ordinary cleanup path. No module-specific
|
||||
logout event or teardown API is introduced.
|
||||
|
||||
### Platform bootstrap and module responses use different status policies
|
||||
|
||||
A `401` from a platform-owned bootstrap request is an authentication challenge in the host's own
|
||||
fixed contract. The platform request helper maps it directly to `HOST_UNAUTHENTICATED`, and the
|
||||
principal-first Dashboard returns to login without treating it as an arbitrary module response. A
|
||||
platform navigation `403` remains an access denial and receives the existing principal recheck so
|
||||
the host can distinguish an authenticated denial from a session that expired between the principal
|
||||
and navigation requests.
|
||||
|
||||
A module owns its response semantics and may return `401` or `403` for reasons that do not invalidate
|
||||
the host session. Either status observed through the module API facade therefore triggers one
|
||||
deduplicated `/api/v1/principal` revalidation while the module still receives its original response:
|
||||
|
||||
- anonymous means the session ended, so the host unmounts the active module and shows login;
|
||||
- authenticated means the session and mounted module remain in place and the original module
|
||||
response remains that module's result;
|
||||
- an infrastructure failure becomes a sanitized retryable host failure, never a fabricated logout.
|
||||
|
||||
A burst of module denials shares the in-flight principal revalidation. A response retained by a
|
||||
module frame that has already unmounted cannot start or overwrite a newer auth transition. A network
|
||||
error, malformed response, `404`, or `5xx` never clears authentication merely because it occurred.
|
||||
Realtime retains ADR 0032's native reconnect and five-minute server reauthorization; when a verified
|
||||
anonymous principal moves the host out of the authenticated state, normal module unmount closes its
|
||||
EventSource.
|
||||
|
||||
### There is no return URL or redirect sink in this pass
|
||||
|
||||
Login renders at the current Dashboard URL and successful authentication replaces that state in
|
||||
place. The host accepts no `return`, `returnTo`, `callbackURL`, or equivalent request/query value,
|
||||
passes no callback URL to Better Auth, and performs no script-driven navigation. External,
|
||||
protocol-relative, backslash-prefixed, or scheme-bearing input therefore has no path to become a
|
||||
redirect target.
|
||||
|
||||
A future client router may define an internal return-location contract, but it must be a separately
|
||||
validated, same-origin route decision. This pass does not create one speculatively.
|
||||
|
||||
### Cookie and origin policy does not broaden for the Dashboard
|
||||
|
||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||
Secure when the canonical identity base URL uses HTTPS, Path `/`, and no `Domain` attribute. It is
|
||||
host-only. Ekklesia does not broaden it to a parent domain merely so sibling subdomains can share a
|
||||
session.
|
||||
|
||||
The simplest production shape is a Dashboard whose `/api` traffic is reverse-proxied on the same
|
||||
origin. A deliberately split browser/API origin remains possible only through the exact canonical
|
||||
trusted-origin allowlist and credentialed CORS already defined by ADR 0011, ADR 0012, and ADR 0018.
|
||||
Wildcards remain invalid. Better Auth's origin and CSRF checks remain explicitly enabled in every
|
||||
environment; the browser supplies its real `Origin` naturally rather than manufacturing or
|
||||
weakening it.
|
||||
|
||||
The repository still provides no production Dashboard hostname, DNS, TLS, reverse-proxy rule,
|
||||
Content-Security-Policy, or static-asset deployment. Host-only cookies mean any eventual choice to
|
||||
place the API and Dashboard on different sibling subdomains must be designed and tested rather than
|
||||
assumed to share a session.
|
||||
|
||||
### Enrollment, recovery, throttling, and first ownership remain explicit gaps
|
||||
|
||||
The login page is for an existing account. It contains no create-account link because public signup
|
||||
is disabled by default and prohibited in production by ADR 0017. It contains no forgot-password or
|
||||
verification flow because Ekklesia has no email sender, recovery delivery, or verified-email
|
||||
contract. Their absence is visible product scope, not a hidden link to a non-operational endpoint.
|
||||
|
||||
Better Auth's sign-in limiter remains enabled with its strict per-path production rule of three
|
||||
attempts per ten seconds. Its store is in-memory and per API process: counters do not span multiple
|
||||
instances and do not survive restart. The server-owned client-address boundary prevents a caller
|
||||
from choosing a new bucket with a spoofed forwarding header, but it does not turn this into a shared
|
||||
or durable brute-force defense. A multi-instance or publicly launched deployment still needs a
|
||||
proven shared or edge limiter.
|
||||
|
||||
The platform-owner bootstrap command still accepts only an **existing** Ekklesia user ID. Because
|
||||
production public signup is prohibited and no invitation or operator-owned user-creation command
|
||||
exists, this repository does not yet provide a supported way to create the first production
|
||||
identity that bootstrap needs. Tests may create a development identity, bootstrap it, and prove the
|
||||
owner's browser session reaches protected Dashboard resources; that evidence must not be described
|
||||
as production first-user provisioning.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An anonymous Dashboard visit reaches a real, accessible email/password login without exposing
|
||||
Better Auth's session token to browser application JavaScript.
|
||||
- `/api/v1/principal` is the only supported browser session read; token-bearing native session and
|
||||
password mutation routes cannot become an accidental parallel contract.
|
||||
- Reload uses the existing cookie and principal endpoint; sign-in and sign-out do not require a
|
||||
client router or full-page navigation.
|
||||
- Authentication transitions tear down the selected module through the generic loader's existing
|
||||
lifecycle, including its realtime cleanup.
|
||||
- A module remains ignorant of Ekklesia's credentials, auth endpoints, cookies, and response-status
|
||||
policy.
|
||||
- An authorization denial does not automatically become logout, while an expired session can still
|
||||
return to login without a reload.
|
||||
- The host-only cookie preserves the narrow existing trust boundary and leaves deployment topology
|
||||
as explicit future work.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Put login in a module or add auth methods to `ModuleUiHostContext`.** Authentication is
|
||||
platform shell policy and would make portable module UI depend on its Ekklesia host.
|
||||
- **Let the browser consume Better Auth's native sign-in success JSON.** Its token and user object
|
||||
duplicate the cookie authority and unnecessarily expose identity/session material to same-realm
|
||||
JavaScript.
|
||||
- **Leave native session endpoints mounted because this UI does not call them.** Same-realm code can
|
||||
call an available route directly; unused token-bearing inspection/mutation endpoints would bypass
|
||||
the principal contract, and `change-password` can issue a raw replacement token.
|
||||
- **Trust the sign-in response as the current user.** `/api/v1/principal` is the narrow Ekklesia
|
||||
identity contract and remains the only authority for host state.
|
||||
- **Treat every module `401` or `403` as session expiry.** A valid authenticated session may receive
|
||||
either status from module-owned behavior; automatic logout would let an untrusted response erase
|
||||
host authentication without consulting the principal authority.
|
||||
- **Accept a callback or return URL for convenience.** The Dashboard has no client router in this
|
||||
pass, so such an input creates redirect risk without a product need.
|
||||
- **Set a parent-domain cookie.** It broadens which hosts receive the credential and prejudges a
|
||||
deployment topology that does not exist.
|
||||
- **Add signup or password-reset links that point to incomplete flows.** Production enrollment and
|
||||
email delivery are not implemented.
|
||||
|
||||
## Deferred
|
||||
|
||||
- invitation or operator-driven production enrollment, verified email, password recovery,
|
||||
authenticated account/password management, and production first-user creation;
|
||||
- shared/durable authentication throttling and proof against the deployed reverse proxy;
|
||||
- a production Dashboard origin, built-asset hosting, CSP, DNS, TLS, and proxy configuration;
|
||||
- a client router and any validated internal post-login return-location contract;
|
||||
- device registration, pairing, unattended credentials, and public-display policy from ADR 0033;
|
||||
- public Website authentication and routing;
|
||||
- standalone-host authentication UX;
|
||||
- every Phase 7B pass 2 product capability.
|
||||
Reference in New Issue
Block a user