|
|
|
@@ -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.
|