AyCode.Core/docs/adr/0001-user-bearer-token-flow.md

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 AddJwtBearer registration, no [Authorize] plumbing, and no per-tag authorization for the single-method OnReceiveMessage(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:

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

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

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

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

  5. 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).

  6. 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).

  7. 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_token URL parameters (consumer Kestrel/proxy config concern; documented in docs/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-9 closed: JWT signing key log-write DEBUG-gated.
  • LOG-I-10 closed: access token log-write DEBUG-gated; AccesToken typo fixed at the same line.

Infrastructure (one-time, before implementation):

  • Create docs/AUTH/ topic folder with README.md (consumer recipe: class names, method signatures, wire formats, diagrams, code examples), AUTH_ISSUES.md, AUTH_TODO.md.
  • Propose new topic code AUTH for the workspace registry (TOPIC_CODES.md row + Decision Log entry LLMP-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.json AyCode:Jwt section.
  • Per-consumer hub: tag allowlist for login + ping (at minimum).

Status migration on AUTH topic creation:

  • Move LOG-I-9 and LOG-I-10 to AUTH_ISSUES.md per their existing "Discovery context" notes; in LOGGING_ISSUES.md replace bodies with cross-ref Superseded 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 Authorization header 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 chosen JwtBearer pipeline keeps that door open.
    • Future flexibility: locks out standard OAuth2 / external-IdP integration when ADR 0002+ extends scope; the chosen JwtBearer pipeline keeps that door open.
  • Pre-flight fix entries: LOGGING_ISSUES.md#log-i-9, LOGGING_ISSUES.md#log-i-10 (both Closed (2026-04-25)).
  • Tentative TODO surfaced post-fix: LOG-T-12 ("Never log secrets" guideline section in LOGGING/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).