8.9 KiB
ADR 0001: User bearer-token authentication flow (HTTP + SignalR + tag dispatch)
Status
Accepted (2026-04-25)
Context
The framework has partial JWT infrastructure on the server side (AcLoginServiceServer produces access + refresh tokens via standard JwtSecurityTokenHandler; IAcLoggedInModelBase.AccessToken returns the token to the client) but no end-to-end bearer-token flow:
- Client-side login service is not implemented (all methods throw
NotImplementedException). - HTTP transport does not inject the bearer header on outgoing calls.
- SignalR transport has no access-token provider wired into the hub-connection builder.
- Server has no
AddJwtBearerregistration, no[Authorize]plumbing, and no per-tag authorization for the single-methodOnReceiveMessage(int tag, ...)SignalR dispatch.
Two security defects in AcLoginServiceServer.GenerateAccessToken (JWT signing key + issued access token written to logger) were addressed pre-flight as LOG-I-9 and LOG-I-10 Closed. Their existence is the proximate trigger for this ADR — they exposed the absence of an end-to-end auth contract.
Consumer apps (MAUI + WASM) cannot adopt the framework safely until this ADR's decisions are implemented.
Cross-project scope: AyCode.Services + AyCode.Services.Server + AyCode.Interfaces + AyCode.Models.Server + every consumer. ADR placed at repo-root docs/adr/ per the multi-project routing rule in .github/skills/adr-author/SKILL.md Step 1 ("cross-cutting decision → highest common ancestor").
In scope: HTTP transport, SignalR transport, per-tag SignalR dispatch authorization, both client (MAUI + WASM) and server.
Out of scope (deferred to future ADRs or TODO):
- Refresh-token flow design (sliding lifetime, proactive vs reactive refresh, server-side token store) — separate ADR
0002. - Role-based / claims-based authorization beyond authenticated / anonymous.
- Multi-tenant claim shape.
- OAuth2 / external-IdP integration.
- Token revocation / blacklist.
- Logout server-side invalidation (currently client-side discard only).
Decision
Adopt a layered bearer-token authentication architecture across all client and server entry points. The architectural choices:
-
Token storage is abstracted at the framework layer. Consumers supply platform-specific implementations (MAUI: secure storage, WASM: protected browser storage). Framework provides the contract and a default in-memory implementation for tests / fallback.
-
HTTP transport binding is automatic. A framework-provided delegating handler reads from the token store and injects the bearer header on every outgoing call. Consumers register one named HttpClient configuration; per-call header injection is eliminated.
-
SignalR transport binding is automatic. A framework-provided hub-builder extension wires the access-token provider from the token store. Browser-hosted clients (WASM) use the standard query-string token-transport fallback because the WebSocket API cannot carry custom headers; server accepts both header and query-string forms transparently for
/hub-prefixed paths. -
Server validates JWTs via the standard ASP.NET Core authentication pipeline. A framework DI bundle binds JWT options from consumer configuration and registers JwtBearer authentication, including hub-aware events that extract the query-string token for SignalR connections.
-
Per-tag SignalR dispatch is gated by an authorization attribute. The hub class requires authentication by default; specific tags (login, ping, public broadcast) are exempted via an explicit allowlist attribute on the hub. The dispatcher checks authorization before resolving the target method for a given tag, keeping the existing
[Tag(N)]registry as the single source of truth (no parallel policy registry). -
Token lifetime is 24 hours, regenerated fresh on each login. Lifetime is configurable per consumer; refresh-flow design is deferred to ADR
0002. When the access token expires the user re-authenticates (no mid-session refresh in this ADR's scope). -
Security hardening is built into the framework setup. JWT options are validated at startup (key length ≥ 256 bits for HS256; issuer + audience non-empty); HTTPS metadata is required outside
Development. Pre-existing log-leak defects were addressed pre-flight (see Consequences → Pre-flight done).
Consequences
Positive:
- Consumers get bearer-auth via three DI calls (one for JWT bundle, one for HTTP-handler registration, one for hub-builder extension) — minimum-boilerplate target met.
- Per-tag authorization scales with the existing tag registry — no parallel registry to maintain.
- Two confirmed-critical security leaks (
LOG-I-9,LOG-I-10) are closed; future similar mistakes prevented by the framework guideline doc (docs/AUTH/README.md's "Never log secrets" section). - WASM and MAUI use one shared token-store abstraction; consumer code is platform-aware only at registration time.
- Framework-first preserved: all architectural pieces live in AyCode.Services / AyCode.Services.Server / AyCode.Interfaces; consumers supply only platform-storage and config values.
Negative:
- 24-hour fixed lifetime forces re-authentication at expiry; user experience friction until ADR
0002(refresh) lands. - Query-string token in WASM SignalR connections → access logs must redact
access_tokenURL parameters (consumer Kestrel/proxy config concern; documented indocs/AUTH/README.md). - Existing consumer projects must add the new DI calls and the per-hub allowlist attribute — breaking change for any consumer currently relying on an unauthenticated hub.
Follow-ups required:
Pre-flight (already landed in this commit series):
- ✅
LOG-I-9closed: JWT signing key log-write DEBUG-gated. - ✅
LOG-I-10closed: access token log-write DEBUG-gated;AccesTokentypo fixed at the same line.
Infrastructure (one-time, before implementation):
- Create
docs/AUTH/topic folder withREADME.md(consumer recipe: class names, method signatures, wire formats, diagrams, code examples),AUTH_ISSUES.md,AUTH_TODO.md. - Propose new topic code
AUTHfor the workspace registry (TOPIC_CODES.mdrow + Decision Log entryLLMP-DEC-N).
Implementation (separate commit series, framework layer):
- Token-store abstraction + in-memory default implementation.
- HTTP delegating handler + DI extension method bundling its registration with a named HttpClient.
- SignalR hub-builder extension (client side) + JWT DI bundle (server side) including hub-aware query-string token events.
- Authorization attribute + tag-allowlist attribute + dispatcher hook in the hub base.
- JWT options POCO + startup
IValidateOptions<T>validator. - Repo-wide grep follow-up: search for any other call sites that may log secrets in similar patterns (preventive).
Consumer wiring (per-consumer projects, separate tasks):
- MAUI token-store implementation (consumer side).
- WASM token-store implementation (consumer side).
- Per-consumer
appsettings.jsonAyCode:Jwtsection. - Per-consumer hub: tag allowlist for login + ping (at minimum).
Status migration on AUTH topic creation:
- Move
LOG-I-9andLOG-I-10toAUTH_ISSUES.mdper their existing "Discovery context" notes; inLOGGING_ISSUES.mdreplace bodies with cross-refSuperseded by AUTH-I-N.
Future ADRs:
0002— Refresh-token flow + sliding-vs-fixed lifetime decision.- Possibly later: role/claims authorization, multi-tenant claims, OAuth2 / external-IdP (per the deferred OUT-of-scope items).
Alternatives considered
- In-memory-only token store (rejected): app-restart re-login on MAUI is unacceptable UX.
- Per-call manual
Authorizationheader injection (rejected): boilerplate + error-prone; framework should encapsulate. - Hub-class-only
[Authorize]without per-tag allowlist (rejected): login + ping tags would also require auth → bootstrap deadlock. - Per-tag DI policy registry (rejected): too much complexity for Layer 0; mixes consumer-specific tag lists into framework.
- Custom token middleware from scratch (rejected): non-standard, no
[Authorize]interop, no out-of-the-box JWT validation.- Future flexibility: locks us out of standard OAuth2 / external-IdP integration when ADR
0002+extends scope; the chosenJwtBearerpipeline keeps that door open. - Future flexibility: locks out standard OAuth2 / external-IdP integration when ADR
0002+extends scope; the chosenJwtBearerpipeline keeps that door open.
- Future flexibility: locks us out of standard OAuth2 / external-IdP integration when ADR
Related
- Pre-flight fix entries:
LOGGING_ISSUES.md#log-i-9,LOGGING_ISSUES.md#log-i-10(bothClosed (2026-04-25)). - Tentative TODO surfaced post-fix:
LOG-T-12("Never log secrets" guideline section inLOGGING/README.md). - External: RFC 6750 (Bearer Token Usage), RFC 7519 (JWT), Microsoft.AspNetCore.SignalR auth docs (query-string token pattern).
- Future: ADR
0002(refresh-flow design).