Architecture Decision Record
ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes
Status
Accepted (2026-07-23). Revised 2026-08-07 (the SecureStorage-backed MAUI storage is hoisted from
the apps into the framework's MAUI-TFM package). Revised 2026-08-14 (SetTokensAsync writes the
refresh token and the access token under one shared guard, so a failed refresh-token write also drops
both tokens). Revised 2026-08-31 (MAUI storage splits into a freshness layer over a raw
ISecureTokenStore, and all three storage services carry the 30-second expiry check plus
single-flight refresh). Revised 2026-10-01 (citations re-anchored; the SkipBearer exemption and the
browser SetTokensAsync throw on a failed cookie write recorded). Revised 2026-10-06: the browser
refresher reports a three-way TokenAcquisition, the browser storage services hold a 15-second
anonymous grace, the MAUI single-flight is process-wide, and the bearer handler leaves an existing
Authorization header alone. Revised 2026-10-07: on the same-origin API proxy hosts (both
consumers) the Blazor Server circuit refreshes and seeds cookies through a protected handoff, the
browser receives only a claims-only token, and the proxy strips and re-stamps the bearer; the MAUI
refresher treats a 409 as transient and keeps the stored pair. See the Revisions below.
Context
ADR-022 and ADR-050 describe the two server halves of authentication: the Blazor host's HttpOnly session cookie that survives SSR prerender (ADR-022), and the Identity service's single rotating refresh token with reuse detection (ADR-050). Neither covers the client half: how a Blazor or MAUI head actually holds an access token, attaches it to API calls, and reacquires one when it nears expiry.
The awkward part is that the same UI code runs under three different render heads with three different safe-storage stories:
- Blazor Server (interactive circuit): no direct DOM access from the server, JS interop is
available only once the circuit is live, and during SSR prerender there is no circuit at all,
only an
HttpContext. - Blazor WebAssembly: runs in the browser with a DOM and an XSS surface, so persisting a refresh token where JS can read it (localStorage) is unsafe.
- MAUI (Blazor Hybrid WebView): a native process with OS-backed secure storage and no cross-origin browser cookie jar to lean on.
A single access token acquisition path cannot serve all three: the browser heads must keep the refresh token out of JS reach, while MAUI has no same-origin UI host to proxy a refresh through and must talk to the API cross-origin. We wanted the application-facing surface (how a page reads auth state, how an outgoing API request gets its bearer token) to be identical across heads, with the head-specific storage and refresh mechanics hidden behind narrow abstractions.
Decision
Model the client token lifecycle as three small abstractions (ITokenStorageService, the
freshness-checked persistence every caller consumes; ISecureTokenStore, raw persistence for the one
head that persists tokens itself; ITokenRefresher for reacquisition) plus a shared bearer-attaching
handler and a shared JWT-driven auth-state provider. Each head registers the implementations that
match its safe-storage story; the UI code above them never branches on render mode.
ITokenRefresherabstracts reacquisition, one implementation per head family. The interface exposes a singleAcquireAccessTokenAsyncthat returns a fresh access token ornullwhen no valid session exists (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenRefresher.cs:13,ITokenRefresher.cs:20). Where the refresh token lives and how rotation happens are internal to the implementation.- Browser heads refresh through the same-origin proxy.
SameOriginProxyTokenRefresher(the WASM client's refresher, and the Blazor Server default until the same-origin API proxy replaces it on the circuit, below) invokesmmcaAuthSession.getTokenover JS interop (SameOriginProxyTokenRefresher.cs:16,SameOriginProxyTokenRefresher.cs:27), which issues aPOST /auth/session/tokenwithcredentials:'same-origin'so the browser sends its HttpOnly auth cookies and the UI host validates-or-refreshes server-side, returning a browser token (the access token by default; a claims-only copy on a same-origin API proxy host, below) (MMCA.Common/Source/Presentation/MMCA.Common.UI/wwwroot/mmca-auth-cookie.js:44-60). The refresh token never reaches JS. The script answersnullonly for the endpoint's 401 and throws on any other non-OK status (mmca-auth-cookie.js:51-56). The refresher also implementsISessionAwareTokenRefresher, whoseTryAcquireAccessTokenAsyncreturns a three-wayTokenAcquisition: acquired, no session (a blank token), or unavailable when interop is missing (SSR prerender, disconnected circuit), the call is cancelled, or the endpoint fails transiently (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISessionAwareTokenRefresher.cs:12,TokenAcquisition.cs:9,SameOriginProxyTokenRefresher.cs:23-37). The plainAcquireAccessTokenAsynccollapses that to the token ornull, so it never throws (SameOriginProxyTokenRefresher.cs:19-20). - On a same-origin API proxy host the browser never holds a credential. Both consumer Server
hosts call
AddCommonSameOriginApiProxyandMapCommonSameOriginApiProxy(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:161,Program.cs:284;MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:153,Program.cs:262; ADR-131). That call replaces the Server circuit'sITokenRefresherwithHandoffTokenRefresherand itsISessionCookieSyncwithHandoffSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:83-84). They callmmcaAuthHandoff.getToken/mmcaAuthHandoff.setCookie(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/HandoffSessionServices.cs:24,HandoffSessionServices.cs:58,mmca-auth-cookie.js:66-100) against/auth/session/handoffand/auth/session-cookie/handoff(SessionHandoffEndpoints.cs:17-18), so the page carries onlySessionHandoffProtectorciphertext and the circuit opens it server-side; a handoff it cannot open is unavailable, not "no session" (HandoffSessionServices.cs:32-34). Boot fails if a later registration displaces either replacement (SameOriginApiProxyEndpointExtensions.cs:64-92). The same call setsClaimsOnlyBrowserTokens(SameOriginApiProxyServiceExtensions.cs:73), so the WASM client's/auth/session/tokenanswers with an unsignedalg:noneclaims copy rather than the access token (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:75-77,SessionClaimsToken.cs:10,SessionClaimsToken.cs:17). - MAUI refreshes directly against the API.
DirectApiTokenRefresherreads the stored access and refresh tokens out of OS SecureStorage throughISecureTokenStore, posts them to the API's cross-originauth/refreshendpoint, and persists the rotated pair back (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/DirectApiTokenRefresher.cs:35-37,DirectApiTokenRefresher.cs:48-49,DirectApiTokenRefresher.cs:56-62,DirectApiTokenRefresher.cs:80). It is anISessionAwareTokenRefreshertoo: a409 Conflict(Auth.RefreshSuperseded) is transient and never clears the stored pair, answering with a newer pair another refresh in this process already stored or otherwise unavailable, so a stolen-and-rotated token is caught by the server's reuse detection (BR-206) on the next presentation (DirectApiTokenRefresher.cs:25-33,DirectApiTokenRefresher.cs:64-67,DirectApiTokenRefresher.cs:88-98). Any other non-success status is reported as no session (DirectApiTokenRefresher.cs:69-72), and MAUI storage hydrates through the collapsingAcquireAccessTokenAsync, so an unavailable result reaches its callers as anullbearer (MauiTokenStorageService.cs:103-104). It takes the raw store rather thanITokenStorageServiceon purpose: every operation it performs is a raw read or write, and depending on the freshness-checking storage instead would close the loop and let a refresh re-enter the acquisition that started it (DirectApiTokenRefresher.cs:12-18). This head has no browser DOM (and thus no XSS surface), so direct token handling is acceptable. ITokenStorageServiceabstracts persistence, one implementation per head. The interface holds access-token and refresh-token get/set/clear (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ITokenStorageService.cs:8). The two browser implementations keep the access token in memory only and never hand a refresh token to JS or to the interactive circuit:WasmTokenStorageService(in MMCA.Common.UI) hydrates the in-memory token on demand from the cookie and returnsnullfor the refresh token unconditionally (WasmTokenStorageService.cs:22,WasmTokenStorageService.cs:50-89,WasmTokenStorageService.cs:92);ServerTokenStorageService(in MMCA.Common.UI.Web) reads the HttpOnly cookie during SSR prerender when anHttpContextis present and holds an in-memory token on the interactive circuit otherwise (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:30,ServerTokenStorageService.cs:70-73,ServerTokenStorageService.cs:76-112). Its refresh-token read follows the same split: the cookie value while anHttpContextis in scope,nullon the circuit, where an HttpOnly cookie is unreachable (ServerTokenStorageService.cs:115-120). Both browser services remember a definitive "no session" for a 15-second anonymous grace, so an anonymous visitor is answered from memory instead of one token POST per API call; only a refresher that reportsNoSessionthroughISessionAwareTokenRefresherstarts it, an unavailable result never does, andSetTokensAsyncends it at once (WasmTokenStorageService.cs:30,WasmTokenStorageService.cs:57-60,WasmTokenStorageService.cs:97,WasmTokenStorageService.cs:125-139,ServerTokenStorageService.cs:40,ServerTokenStorageService.cs:81-84,ServerTokenStorageService.cs:125,ServerTokenStorageService.cs:153-167). The MAUI implementation is framework-shared too and is a pair:MauiTokenStorageServiceis the freshness-checking layer (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiTokenStorageService.cs:32-34) overMauiSecureTokenStore, which backs ontoSecureStorage.Default(platform secure enclaves) and guards every read and write so an OS-invalidated keystore entry degrades to one clean re-login instead of an unhandled throw on launch (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiSecureTokenStore.cs:22,MauiSecureTokenStore.cs:69-85,MauiSecureTokenStore.cs:91-104,MauiSecureTokenStore.cs:107-120); both MAUI heads register the pair through the oneAddCommonMauiTokenStoragecall (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:103,DependencyInjection.cs:105-106).ISecureTokenStoreisolates raw persistence, and only MAUI implements it. It reads back exactly what was written and never triggers a refresh (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16). Splitting it out ofITokenStorageServiceis what keeps the MAUI graph acyclic: storage depends on the refresher, and the refresher depends on the raw store rather than back on storage (ISecureTokenStore.cs:4-9). The browser heads implement nothing here, because they hold the access token in memory and leave the refresh token in an HttpOnly cookie, so they have no raw store to expose (ISecureTokenStore.cs:10-14).- Login seeds the browser HttpOnly cookie through a JS fetch.
SetTokensAsyncon both browser storage services caches the access token in memory and callsISessionCookieSync.SyncAsync, which fires a browser fetch to/auth/session-cookieso the resultingSet-Cookielands in the user's cookie jar in both Server interactive mode and WASM (WasmTokenStorageService.cs:94-107,ISessionCookieSync.cs:8,MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:13,JsFetchSessionCookieSync.cs:19,mmca-auth-cookie.js:7). The refresh token transits JS only for that single same-origin POST and is never persisted in localStorage. The sync is registered viaAddClientAuthSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:239,DependencyInjection.cs:241). That is the default mode. On a same-origin API proxy host the Server circuit seeds the cookies through the protected handoff instead (above), and in WASM the proxy'sTokenIssuingrewrite writes the cookies from the sign-in response and hands the browser the claims-only token withrefreshTokenemptied (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:122-144); the WASM client still registersAddClientAuthSessionCookieSync(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:52,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:49), but itsPOST /auth/session-cookiethen writes nothing and answers 204 (SessionCookieEndpoints.cs:36-46). - Every outgoing API request is bearer-stamped by one handler.
AuthDelegatingHandlerreads the current access token fromITokenStorageServiceand attaches it as aBearerauthorization header (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:14,AuthDelegatingHandler.cs:36,AuthDelegatingHandler.cs:39). It is registered into the shared named"APIClient"HttpClient pipeline viaAddHttpMessageHandler(DependencyInjection.cs:114,DependencyInjection.cs:124,DependencyInjection.cs:146), so the handler is head-agnostic: it depends only on the storage abstraction, which supplies the correctly-hydrated token per head. Two requests pass through without reading storage: one that sets theSkipBeareroption (AuthDelegatingHandler.cs:22,AuthDelegatingHandler.cs:29-32), which the MAUI refresh POST sets (DirectApiTokenRefresher.cs:61), and one that already carries anAuthorizationheader, so a forced-refresh replay keeps the token it just acquired instead of resending the rejected one (AuthDelegatingHandler.cs:9-12,AuthDelegatingHandler.cs:34). On a WASM client of a proxy host the handler's bearer never reaches the API: the"APIClient"base address is the proxy endpoint and an innermostSameOriginProxyRequestHandlerstrips everyAuthorizationheader (DependencyInjection.cs:120-121,DependencyInjection.cs:136,DependencyInjection.cs:149-155), and the host proxy stamps the cookie session's bearer server-side: the endpoint takes the bearer from the validated-or-refreshed cookie session and the transformer sets it as theAuthorizationheader (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:86-95,SameOriginProxyTransformer.cs:60). - Blazor auth state is derived from the JWT client-side.
JwtAuthenticationStateProviderreads the stored access token, parses and expiry-checks it without server validation, and builds an authenticatedClaimsPrincipalfrom the token's claims, falling back to anonymous on any failure (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:13,JwtAuthenticationStateProvider.cs:33-48,JwtAuthenticationStateProvider.cs:50-53). Client-side parsing keeps the UI responsive (AuthorizeViewreacts immediately after login or logout viaNotifyUserAuthentication/NotifyUserLogout,JwtAuthenticationStateProvider.cs:60,JwtAuthenticationStateProvider.cs:72); the WebAPI still performs full token validation on every request. - Concurrent callers share one refresh. All three storage services proactively reacquire when the
token they hold is within a 30-second expiry skew and collapse concurrent acquisitions (delegating
handler, auth-state provider, SignalR) onto a single in-flight hydration
(
WasmTokenStorageService.cs:27,WasmTokenStorageService.cs:66-71,ServerTokenStorageService.cs:37,ServerTokenStorageService.cs:90-95,MauiTokenStorageService.cs:36,MauiTokenStorageService.cs:57-66). The browser services hold that slot per instance; on MAUI it is process-wide (static), because theAuthDelegatingHandlerthatIHttpClientFactorybuilds resolves its own scoped storage instance in its handler scope, and a per-instance slot let that scope and the component scope each rotate the same refresh token, which the server reads as reuse and answers by revoking the session (MauiTokenStorageService.cs:24-30,MauiTokenStorageService.cs:38-40,MauiTokenStorageService.cs:70-76). On MAUI the check reads through the raw store first, so a token recovered from the enclave hours later is refreshed rather than handed to a caller as a bearer that answers 401 (MauiTokenStorageService.cs:45-49). - Each head wires its own trio in Program.cs. The WASM client registers
WasmTokenStorageServiceSameOriginProxyTokenRefresher+JwtAuthenticationStateProvider(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web.Client/Program.cs:45-47,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:48-50); the Blazor Server host registersServerTokenStorageServiceviaAddCommonServerTokenStorage(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:41,DependencyInjection.cs:50) plus the same proxy refresher and auth-state provider (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:142-143,Program.cs:155;MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:150-151,Program.cs:163), and in both hosts the laterAddCommonSameOriginApiProxycall overrides that refresher withHandoffTokenRefresher(MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:153,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:161); the MAUI host registers the shared SecureStorage-backed pair viaAddCommonMauiTokenStorageplusDirectApiTokenRefresher+JwtAuthenticationStateProvider(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:165-167,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97-99).
Rationale
- One application surface, three storage stories. Pages, services, and the HTTP pipeline talk to
ITokenStorageServiceandAuthenticationStateProvideronly; the head-specific choice of HttpOnly cookie versus SecureStorage lives entirely behind those abstractions and the Program.cs registration, so UI code never branches on render mode.ISecureTokenStoreis below that line again: nothing outside the MAUI package and its refresher consumes it. - Keep the refresh token off the highest-risk surface. The browser heads are the ones with an XSS
attack surface, so their refresh token stays in an HttpOnly cookie and rotation happens server-side
through the same-origin proxy; the token the browser holds is memory-only and short-lived, and on a
same-origin API proxy host it is only a claims-only copy, never the credential
(
SessionCookieEndpoints.cs:75-77,SameOriginProxyTransformer.cs:142-143). MAUI, with no DOM, can safely hold both tokens in the OS secure enclave. - Reuse the ADR-022 cookie plumbing rather than duplicate it. The browser refresher is a thin JS
interop call onto the same
/auth/session/tokenand/auth/session-cookieendpoints ADR-022 already stands up, so the default path adds the client lifecycle without a second server mechanism. A same-origin API proxy host does add one, the handoff endpoints and the proxy itself (SessionHandoffEndpoints.cs:17-18; ADR-131), so that the browser holds no credential at all. - Single-flight refresh avoids a token stampede. On a heavily-concurrent page (delegating handler, auth-state, SignalR all asking at once) the shared in-flight hydration means one network round-trip, not several racing refreshes.
Trade-offs
- The browser heads depend on the same-origin UI host.
SameOriginProxyTokenRefresheronly works where the UI host serves the/auth/session/*endpoints; a browser head deployed without that plumbing (ADR-022) cannot refresh. MAUI has no such dependency but pays for it with cross-origin direct token handling. - Client-side JWT parsing is advisory, not authoritative.
JwtAuthenticationStateProvidertrusts the token's shape and expiry for UI responsiveness and does no signature validation; the security boundary is the WebAPI, which validates every request. A tampered local token can flip anAuthorizeViewbut cannot pass an API call. - MAUI storage is shared, but only from a MAUI-TFM package. All three storage services are
framework-owned, yet the SecureStorage-backed pair depends on the MAUI
SecureStorageAPI, so it cannot sit beside its siblings in MMCA.Common.UI: it lives inMMCA.Common.UI.Maui, which is deliberately outsideMMCA.Common.slnx(the solution's Presentation folder listsMMCA.Common.API,MMCA.Common.Grpc,MMCA.Common.UIandMMCA.Common.UI.Webbut not the MAUI package,MMCA.Common/MMCA.Common.slnx:19-24) and is built across its four TFMs by a separate windows-only CI job (MMCA.Common/.github/workflows/ci.yml:173-178,ci.yml:179-182,ci.yml:214-216; ADR-042). A change to the MAUI storage is therefore verified on a different, slower path than the browser ones. - The split multiplies the paths to keep correct. Three
ITokenStorageServiceimplementations, the MAUI-onlyISecureTokenStorebeneath one of them (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiSecureTokenStore.cs:22), two refreshers plus the proxy's handoff refresher, and two cookie-syncs, mean the same login/refresh/logout invariant is expressed in several places; each head's registration set must stay consistent or a head silently loses auth, and the MAUI head's storage registration is a pair rather than a single line.
Related
ADR-022 (the Blazor host's HttpOnly session cookie and the /auth/session/* endpoints the browser
refresher proxies through), ADR-050 (the single rotating refresh token with reuse detection that the
auth/refresh endpoint enforces and that this client lifecycle acquires against), ADR-042 (the
device-capability abstraction and the MAUI head whose SecureStorage backs DirectApiTokenRefresher
and the shared storage pair, MauiTokenStorageService over MauiSecureTokenStore, in the MAUI-TFM
package), ADR-131 (the same-origin API proxy whose handoff refresher, handoff cookie sync and
claims-only browser tokens replace the browser credential path on both consumer hosts).
Revision (2026-08-07)
The MAUI half of ITokenStorageService is no longer app-local. The original Decision left the
SecureStorage-backed implementation in each app because it depends on the MAUI SecureStorage API;
it has since been hoisted into the framework's MAUI-TFM package, so all three storage
implementations are now framework-owned.
- Framework-owned, in MMCA.Common.UI.Maui. Both tokens live under the
auth_access_token/auth_refresh_tokenkeys inSecureStorage.Default, in the sealed class the 2026-08-31 Revision below namesMauiSecureTokenStore(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiSecureTokenStore.cs:22,MauiSecureTokenStore.cs:24-25). The per-app copies are gone: a workspace-wide search for the MAUI storage types returns only the framework files underMMCA.Common.UI.Maui, nothing underMMCA.ADC/Source/orMMCA.Store/Source/, so the "behavior can drift between the two apps" risk the Trade-offs section recorded no longer applies. - Both heads register it through one extension method.
AddCommonMauiTokenStorage()is the single call each MAUI host makes (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:97,MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:163,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97). Its registrations are scoped, not singleton, to match the two browser siblings so component code depends on one lifetime on every head (DependencyInjection.cs:99-100, rationale atDependencyInjection.cs:92-95). The rest of each MAUI trio is unchanged:DirectApiTokenRefresher+JwtAuthenticationStateProviderstill follow it (MauiProgram.cs:164-165in ADC,MauiProgram.cs:98-99in Store). - The hoist added failure handling the app copies did not have. Every read and write is guarded,
because the OS invalidates keystore entries on its own schedule and the raw API then throws rather
than returning nothing: a failed read drops the unreadable entry and degrades to "no token stored"
(
MauiSecureTokenStore.cs:69-85), a failed write retries once against a freshly removed key and otherwise propagates (MauiSecureTokenStore.cs:91-104),ClearTokensAsyncis best-effort so logout always succeeds (MauiSecureTokenStore.cs:56-63,:107-120), andSetTokensAsyncwrites both tokens under one shared guard so either write failing drops both, rather than leaving a mismatched pair (MauiSecureTokenStore.cs:34-53). - What it cost. The classes cannot live in
MMCA.Common.UI(that package must stay Blazor-WASM-compatible and MAUI-free), so they sit inMMCA.Common.UI.Maui, outsideMMCA.Common.slnxand built across its four TFMs by a windows-only CI job (ADR-042). The Trade-offs bullet above is rewritten accordingly: the risk is no longer divergent copies, it is a slower and separate verification path for the one shared copy.
Revision (2026-08-14)
SetTokensAsync closed a gap the original hoist left open. Point 3 above previously described the
method as writing the refresh token first and dropping both tokens only when the following
access-token write failed, which left a window where a failing refresh-token write escaped before
the guard was entered and left the OLD pair in place: the app then held a stale access token it
believed was current until a manual sign-out cleared it.
SetTokensAsync performs both writes inside one try/catch, so either write failing drops both
tokens and forces a clean re-login instead of leaving a stale, partially-updated pair
(MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiSecureTokenStore.cs:34-53; the
comment at :36-39 records the trap). Point 3's description above is updated accordingly: read
guarding, write-retry, and best-effort clearing are all unchanged, only the SetTokensAsync behavior
narrowed.
Revision (2026-08-31)
MAUI storage is two classes, and the freshness check the browser heads always had is now on every head.
- A raw store beneath the storage service.
ISecureTokenStoreis the third abstraction: raw persistence, no freshness semantics, implemented only where a head persists tokens itself (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/Tokens/ISecureTokenStore.cs:16). The MAUI implementationMauiSecureTokenStoreholds every line ofSecureStorage.Defaulthandling and all the guarding the 2026-08-07 Revision describes (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/Services/MauiSecureTokenStore.cs:22);MauiTokenStorageServicekeeps theITokenStorageServicesurface and delegates the raw reads and writes to it (MauiTokenStorageService.cs:69,MauiTokenStorageService.cs:72-73,MauiTokenStorageService.cs:76). - The split is what keeps the graph acyclic. Storage depends on the refresher, and
DirectApiTokenRefresherdepends on the raw store rather than back on storage (DirectApiTokenRefresher.cs:18-20, rationale atDirectApiTokenRefresher.cs:10-16,ISecureTokenStore.cs:4-9). Depending onITokenStorageServicethere would let a refresh re-enter the acquisition that started it. - MAUI reads through an expiry check.
MauiTokenStorageServicerefreshes proactively when the stored token is within the same 30-second skew the browser services use, and collapses concurrent callers onto one in-flight acquisition (MauiTokenStorageService.cs:23,MauiTokenStorageService.cs:30-36,MauiTokenStorageService.cs:43-48). A token read back from the enclave can be hours or days old, so returning it verbatim handed the delegating handler, the auth-state provider and SignalR an expired bearer, which the user experienced as a random sign-out. - Registration is a pair, still one call.
AddCommonMauiTokenStorage()registers both the raw store and the storage service, both scoped (MMCA.Common/Source/Presentation/MMCA.Common.UI.Maui/DependencyInjection.cs:97,DependencyInjection.cs:99-100), so each MAUI host's wiring is unchanged (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:163-165,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiProgram.cs:97-99). Both halves are required: the storage service cannot resolve without a raw store behind it.
Revision (2026-10-01)
No decision or rationale changed. Citations in Decision and Trade-offs are re-anchored to the current
source (DirectApiTokenRefresher, JsFetchSessionCookieSync, mmca-auth-cookie.js,
AuthDelegatingHandler, the three storage services, both DependencyInjection.cs files, the ADC and
Store host Program.cs files, MMCA.Common.slnx and the windows MAUI job in ci.yml). Two statements
are corrected where they live. The Trade-offs bullet now lists all four projects in the slnx
Presentation folder (MMCA.Common/MMCA.Common.slnx:19-24), which never held only the two UI
packages. The bearer-handler bullet now records the SkipBearer request option: the MAUI refresh POST
goes through the same "APIClient" pipeline as every other call, so it opts out of the bearer header
and never reads the storage instance that is awaiting it, which is the HTTP-pipeline counterpart of
the raw-store split
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:15-20,
AuthDelegatingHandler.cs:27-30, DirectApiTokenRefresher.cs:18-22, DirectApiTokenRefresher.cs:46).
Also previously unrecorded: SetTokensAsync on both browser storage services throws
InvalidOperationException when the session-cookie write reports failure, after the in-memory token
is set, so a login whose cookie was never written surfaces as an error rather than signing out at the
first access-token expiry (WasmTokenStorageService.cs:68-71, ServerTokenStorageService.cs:89-92).
Revision (2026-10-06)
No decision changed; four behaviors the Decision did not record are now stated where they live.
- The browser refresher tells "no session" from "unavailable".
SameOriginProxyTokenRefresherimplementsISessionAwareTokenRefresherand returns a three-wayTokenAcquisition; the script answersnullonly on a 401 and throws on any other non-OK status (SameOriginProxyTokenRefresher.cs:23-37,mmca-auth-cookie.js:51-56). The earlier wording ("returnsnullrather than throwing") now holds only for the collapsingAcquireAccessTokenAsync(SameOriginProxyTokenRefresher.cs:19-20). - Both browser storage services hold a 15-second anonymous grace, started only by a definitive
no-session answer and cleared by
SetTokensAsync(WasmTokenStorageService.cs:30,ServerTokenStorageService.cs:40). Each also gained a constructor taking aTimeProvider, with the original constructor kept (WasmTokenStorageService.cs:22-25,WasmTokenStorageService.cs:45). - The MAUI single-flight is process-wide, not per instance, so the handler scope and the
component scope cannot each rotate the same refresh token (
MauiTokenStorageService.cs:24-30,MauiTokenStorageService.cs:70-76). AuthDelegatingHandlerleaves a request that already carries anAuthorizationheader untouched, so a forced-refresh replay is not overwritten with the rejected stored token (AuthDelegatingHandler.cs:34).
All path:line anchors in Status, Decision and Trade-offs were re-verified against current source.
The MAUI registration cited in the 2026-08-07 and 2026-08-31 Revisions now sits at
MMCA.Common.UI.Maui/DependencyInjection.cs:103 and :105-106, the ADC MAUI wiring at
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:165-167, and the browser SetTokensAsync
throw at WasmTokenStorageService.cs:103-106 and ServerTokenStorageService.cs:131-134.
Revision (2026-10-07)
Re-verified against current source. The three abstractions, the shared bearer handler, the client-side auth-state provider and the MAUI storage story are unchanged. What moved is the browser credential path on the same-origin API proxy hosts, which the Decision attributed to the default refresher and cookie sync: there the browser holds only a claims-only token and the handoff endpoints plus the proxy are a second server mechanism, so the Rationale bullets on the browser token and on reusing the ADR-022 plumbing are qualified accordingly. Two MAUI refresher outcomes the Decision did not record are also stated.
- The Server circuit uses the protected handoff on both consumer hosts.
AddCommonSameOriginApiProxyreplacesITokenRefresherwithHandoffTokenRefresherandISessionCookieSyncwithHandoffSessionCookieSync(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:83-84), and both hosts call it after their own registrations (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:161,MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:153). The page carries onlySessionHandoffProtectorciphertext (HandoffSessionServices.cs:24,HandoffSessionServices.cs:58,mmca-auth-cookie.js:66-100,SessionHandoffEndpoints.cs:17-18), so the circuit'smmcaAuthSession.getTokenandmmcaAuthCookie.setpaths are not used there, and boot fails if a later registration displaces either (SameOriginApiProxyEndpointExtensions.cs:64-92). ADR-131 is now cross-referenced in Related. - Claims-only browser tokens. The proxy sets
ClaimsOnlyBrowserTokens(SameOriginApiProxyServiceExtensions.cs:73):/auth/session/tokenreturns an unsigned claims copy (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:75-77),POST /auth/session-cookiewrites nothing and answers 204 (SessionCookieEndpoints.cs:36-46), and WASM sign-in cookies are written by the proxy'sTokenIssuingrewrite, which emptiesrefreshTokenin the body (SameOriginProxyTransformer.cs:122-144). - The WASM bearer is stripped and re-stamped. A WASM client of a proxy host points
"APIClient"at the proxy and adds an innermost handler that stripsAuthorization(MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:120-121,DependencyInjection.cs:136,DependencyInjection.cs:149-155); the proxy stamps the cookie session's bearer server-side (SameOriginApiProxyEndpoint.cs:86-95,SameOriginProxyTransformer.cs:60). - MAUI refresher outcomes.
DirectApiTokenRefresheris a sealedISessionAwareTokenRefresher(DirectApiTokenRefresher.cs:35-37). A 409 (Auth.RefreshSuperseded) is transient and never clears the stored pair (DirectApiTokenRefresher.cs:25-33,DirectApiTokenRefresher.cs:64-67,DirectApiTokenRefresher.cs:88-98); any other non-success status is no session (DirectApiTokenRefresher.cs:69-72); MAUI storage hydrates through the collapsing overload, so an unavailable result reaches callers as anullbearer (MauiTokenStorageService.cs:103-104). - Anchors re-verified against current source:
DirectApiTokenRefresher.cs:48-49(reads),:56-62(POST),:61(SkipBearer),:80(persist),:12-18(raw-store rationale); the ADC Server host atMMCA.ADC.UI.Web/Program.cs:150-151and:163; the windows MAUI job atMMCA.Common/.github/workflows/ci.yml:173-178,:179-182and:214-216(the MAUI build step, formerly cited at:239-241, moved up when lines above it were removed). TheMauiTokenStorageServicedelegation the 2026-08-31 Revision cites now sits atMauiTokenStorageService.cs:94,:97-98and:101.