Skip to content

Commit 4235ee3

Browse files
kazo0claude
andcommitted
feat(msal): persist the token cache on WebAssembly through IKeyValueStorage
Layer 3/4 of the MSAL authentication work (#3139). Before this the MSAL cache was memory-only in the browser, so every page reload meant signing in again. - MsalCacheHelper has no browser backend, so on WebAssembly the provider registers SetBeforeAccessAsync/SetAfterAccessAsync callbacks that serialize the cache through MsalTokenCacheStore into the host's default IKeyValueStorage, keyed MsalCache_{ClientId}. Which store that is - and so whether the cache survives a reload or a tab close - is the storage layer's KeyValueStorageConfiguration:BrowserCacheLocation (localStorage by default); this side only reads and writes the blob, so MemoryStorage yields the old in-memory behavior for free - MsalKeyValueStorage wraps the resolved default store for injection, since a bare IKeyValueStorage registration resolves to the in-memory one - Sign-out and ITokenCache.Cleared both delete the serialized cache, in a finally and with CancellationToken.None, so a cancelled or failed sign-out cannot leave refresh-token material behind - A warning names the store whenever the cache is persisted unprotected: the serialized cache holds the refresh token, and only an Entra `spa` registration caps that token at 24 non-sliding hours Tests: Given_MsalTokenCacheStore (unit) covers the round trip, key layout, corrupt-blob handling and no-op writes; the UI suite gains the sign-out / cancelled sign-out / Cleared purge cases and the "persisted only on WebAssembly" guard, and the WebAssembly lane runs it. Docs: the browser cache section of the how-to (store choice, the `spa` prerequisite and why it matters) and the migration note. Spec 011 carries the design trail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqfQh5ovrPEvFGcERLiLhH
1 parent b21e80e commit 4235ee3

13 files changed

Lines changed: 1954 additions & 9 deletions

File tree

doc/Learn/Authentication/HowTo-MsalAuthentication.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ uid: Uno.Extensions.Authentication.HowToMsalAuthentication
1717
| Desktop (Skia) — Linux | ✅ System browser | ✅ Keyring/libsecret |
1818
| Android | ✅ Browser / custom tab | ✅ Handled natively by MSAL |
1919
| iOS | ✅ Web authentication session | ✅ Handled natively by MSAL — [keychain entitlement required](#ios-keychain-access-group) |
20-
| WebAssembly | ✅ Popup | In-memory only (tokens don't survive a page reload) |
20+
| WebAssembly | ✅ Popup | ✅ Browser storage, `localStorage` by default — cleartext, see [below](#webassembly-token-cache) |
2121
| Mac Catalyst | ❌ Not supported (`AddMsal` throws `PlatformNotSupportedException`) ||
2222

2323
MSAL's own cache (refresh and ID tokens) is what the last column describes. The access token that `IAuthenticationService` hands to HTTP handlers is kept separately, in the host's default `IKeyValueStorage``KeyStore` / Keychain on native Android and iOS, but plain `ApplicationData` on Android and iOS heads built with `UnoFeatures=SkiaRenderer`, where the Uno SDK loads the storage package's plain `netX.0` build. See [Key-value storage](xref:Uno.Extensions.Storage.Overview#key-value-storage).
@@ -27,6 +27,10 @@ The set of identity scenarios (Microsoft accounts, work/school accounts, B2C, so
2727
## Prerequisites
2828

2929
- An app registration on the Microsoft identity platform, with each platform's redirect URI registered — see [Redirect URIs](#4-redirect-uris) for the value the provider applies on each target.
30+
- **If you target WebAssembly**, the browser redirect URI must be registered under the **`spa`** platform of the app registration, not as a public-client/native URI. This is not a formality:
31+
- MSAL.NET issues the token request from the browser over `fetch`, which CORS only permits for a redirect URI registered as `spa`.
32+
- A `spa` registration caps refresh tokens at **24 hours, non-sliding** — as opposed to the 90-day sliding tokens a public-client registration issues. That cap is what makes it acceptable to keep the token cache in browser storage at all, since browser storage is readable by any script on the origin. See [Refresh tokens](https://learn.microsoft.com/entra/identity-platform/refresh-tokens) and [the SPA cookie reference](https://learn.microsoft.com/entra/identity-platform/reference-third-party-cookies-spas).
33+
- Consequence to design for: re-authentication has to happen in the top-level frame, and users sign in again at least daily.
3034

3135
## Step-by-step
3236

@@ -425,6 +429,44 @@ With the fallback enabled, the provider logs a warning (including the fallback f
425429

426430
On Android and iOS the token cache is persisted natively by MSAL — no configuration is needed (or honored) on those platforms. On iOS it lands in a keychain access group the app has to grant first; see [iOS: keychain access group](#ios-keychain-access-group).
427431

432+
#### WebAssembly token cache
433+
434+
In the browser there is no protected store to write to, so the cache goes to browser storage in cleartext and what bounds the exposure is *lifetime*, not encryption.
435+
436+
The default is `localStorage` — the store WebAssembly already used for the token cache before this setting existed, so upgrading the package never relocates an app's data. It is **not** MSAL.js's default: msal-browser defaults to `sessionStorage`, and if you are starting fresh that is the tighter choice, because the serialized cache is dropped when the tab closes rather than persisting across browser restarts. Opt into it explicitly:
437+
438+
It is a **storage** setting, not an MSAL one — it selects the host's single default key-value store, which the token cache shares with everything else built on it:
439+
440+
```json
441+
{
442+
"KeyValueStorageConfiguration": {
443+
"BrowserCacheLocation": "SessionStorage"
444+
}
445+
}
446+
```
447+
448+
| Value | Behavior |
449+
| --- | --- |
450+
| `LocalStorage` (default) | Survives a page reload, closing the tab, and restarting the browser — the widest window in which a stolen cache stays usable. The default only because it is what WebAssembly already used. |
451+
| `SessionStorage` | Survives a page reload; cleared when the tab closes. Matches MSAL.js and is the tighter choice for credentials. |
452+
| `MemoryStorage` | Nothing is written to browser storage; the user signs in again after every reload. |
453+
454+
An invalid value throws while the host is being built rather than silently falling back.
455+
456+
One setting covers the Uno token cache (the access token) and the MSAL cache (the refresh and ID tokens) — splitting them would let the access token outlive both the tab and the refresh token. Because it belongs to storage rather than to a provider, it applies whatever you name your provider (`AddMsal(window, name: "MyMsal")`) and whichever provider you use. It is ignored on every other platform, where the platform's own protected store applies.
457+
458+
Signing out removes both: `LogoutAsync` removes every signed-in account and then deletes the serialized MSAL cache. Note this clears *our* storage, not the identity provider's session cookie — the next "Sign in" may complete without a prompt because the IdP still recognises the browser. Use the `end_session_endpoint` if you need a full sign-out. Clearing `ITokenCache` directly has the same storage effect.
459+
460+
> [!IMPORTANT]
461+
> Whichever persistent option you pick, the serialized cache holds the **refresh token** in cleartext, readable by any script on the origin. What bounds the exposure is that your WebAssembly redirect URI must be registered under the Entra **`spa`** platform (see [Prerequisites](#prerequisites)), which caps refresh tokens at **24 hours, non-sliding**. A public-client registration issues a **90-day sliding** token instead, and nothing in the library can detect which one your tenant issued — the provider logs a warning naming the store on every unprotected persist, but the registration type is yours to get right. See [Refresh tokens in the Microsoft identity platform](https://learn.microsoft.com/entra/identity-platform/refresh-tokens).
462+
463+
For full control over the storage properties on desktop targets, use the `Storage()` extension method to configure the underlying `StorageCreationPropertiesBuilder`:
464+
465+
```csharp
466+
builder.AddMsal(window, msal =>
467+
msal.Storage(store => store.WithMacKeyChain("com.contoso.myapp.msal", "MyAppCache")));
468+
```
469+
428470
### 8. Use the provider in your application
429471

430472
> [!IMPORTANT]

doc/Learn/UpdatingExtensions.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ These are binary-compatible but observable. Read them if your app calls `AddMsal
3434
either remove it — the provider derives the Android and iOS values — or guard it with
3535
`OperatingSystem.IsBrowser()`; otherwise WebAssembly sign-in fails with a redirect-URI mismatch.
3636

37+
- **WebAssembly: the token cache is persisted by default.** Before 7.4 the MSAL cache lived in
38+
memory only, so a page reload meant signing in again. It is now serialized through the host's
39+
default `IKeyValueStorage``localStorage`, under the key `MsalCache_{ClientId}` — and therefore
40+
holds the **refresh token** in cleartext browser storage. Register the redirect URI under the
41+
Entra `spa` platform so that token is capped at 24 non-sliding hours (see the how-to's
42+
[prerequisites](xref:Uno.Extensions.Authentication.HowToMsalAuthentication#prerequisites)). To
43+
keep the pre-7.4 behavior set `KeyValueStorageConfiguration:BrowserCacheLocation` to
44+
`MemoryStorage`; `SessionStorage` is the middle ground. Note that switching to `MemoryStorage`
45+
(or downgrading) does not delete an entry a previous run left in `localStorage` — sign out first,
46+
or clear the site's data.
47+
3748
- **`Builder(...)` runs last.** Your `PublicClientApplicationBuilder` callback now runs after the
3849
platform redirect URI, the Windows broker and `WithUnoHelpers()` have been applied, so what it sets
3950
wins. Previously `WithUnoHelpers()` ran after it and, on WebAssembly, replaced an `HttpClient`

0 commit comments

Comments
 (0)