YabbaDeck.Sso.Web
2.1.0
dotnet add package YabbaDeck.Sso.Web --version 2.1.0
NuGet\Install-Package YabbaDeck.Sso.Web -Version 2.1.0
<PackageReference Include="YabbaDeck.Sso.Web" Version="2.1.0" />
<PackageVersion Include="YabbaDeck.Sso.Web" Version="2.1.0" />
<PackageReference Include="YabbaDeck.Sso.Web" />
paket add YabbaDeck.Sso.Web --version 2.1.0
#r "nuget: YabbaDeck.Sso.Web, 2.1.0"
#:package YabbaDeck.Sso.Web@2.1.0
#addin nuget:?package=YabbaDeck.Sso.Web&version=2.1.0
#tool nuget:?package=YabbaDeck.Sso.Web&version=2.1.0
YabbaDeck.Sso.Web
Operator sign-in for the YabbaDeck platform's management
consoles — the Blazor WebAssembly half. Pair it with
YabbaDeck.Sso on the API.
Why there are two packages
A Blazor WebAssembly publish cannot resolve the Microsoft.AspNetCore.App shared framework for
browser-wasm, and the server half needs it. So the two halves ship separately and never reference
each other — a reference from here to YabbaDeck.Sso compiles cleanly and then fails the front-end
publish. A few small types therefore exist on both sides — the AuthMode enum, and the exchange
payloads; no single project ever loads both packages, so they are the two ends of an HTTP boundary
rather than duplication.
The flow this package's half of
- The operator clicks Sign in with Synology; the browser leaves for the API's challenge path.
- The API runs the whole OIDC handshake — it holds the client secret, and the browser never meets the identity provider.
- The API redirects back to this app's callback route carrying a single-use code, never a token.
- This app
POSTs that code to the API's exchange path, gets the bearer token, writes it into the token store and resumes wherever the operator was going.
Or, when that is exactly what cannot happen: the operator fills in the break-glass credential and
this app posts it to LocalLoginPath, which answers with the same token in one round trip. No
redirect, no code, nothing that touches the identity provider.
Configuration
In wwwroot/appsettings.json (and its per-environment overlay), so one published bundle can be
pointed at dev, QA or production:
{
"Auth": {
"Sso": {
// sso | local | both — case-insensitive.
"Mode": "both",
"ChallengePath": "/api/auth/sso/challenge",
"ExchangePath": "/api/auth/sso/exchange",
// Where the break-glass credential is posted. Configure it in every mode, not just the two
// that render the form — an operator reaching for it is already having a bad day.
"LocalLoginPath": "/api/auth/local/login",
"CallbackRoute": "/signin-callback",
// Where a failed sign-in lands. Must match the API's WebLoginPath.
"LoginRoute": "/login"
}
}
}
The paths are relative to the API base URL the host app already configures for its own API client;
this package does not restate it. CallbackRoute must match the WebCallbackPath the API is
configured with — that is what the API redirects to.
Mode is not a security decision. It chooses what the login card renders; the API decides what
it will actually accept.
LoginRoute is where a failed sign-in lands — the page the break-glass form is on, which is why
every failure ends there rather than where it happened.
Wiring it up
// Settings, the HttpClient the sign-in endpoints are called through, and ISsoSignInService.
builder.Services.AddYabbaDeckSsoClient(builder.Configuration, (sp, http) =>
http.BaseAddress = new Uri(apiBaseUrl));
// The token store and the sessionStorage persistence behind it. SINGLETON (see below).
builder.Services.AddYabbaDeckSsoTokenStore();
// Auth state: either this package's provider…
builder.Services.AddYabbaDeckSsoAuthenticationState();
// …or your own, with ISsoAuthenticationNotifier implemented on it and resolving to the same
// instance the router reads.
// …and then, at the bottom of Program, BEFORE RunAsync:
var host = builder.Build();
await host.RestoreSsoSessionAsync();
await host.RunAsync();
The client's BaseAddress is the API's origin — the one the app already configures for its own API
client. The challenge URL is built from it too, so the API's location is stated once and this package
never restates it in its own configuration.
Do not add the app's bearer-token handler to that client. All three sign-in endpoints are anonymous, and the token it would attach is the one sign-in is trying to replace.
ValidateOnStart() is registered, but a standalone WebAssembly app has no generic host to run
startup validators — there the annotations are checked on first resolve, which the login surface does
before it renders.
The two components
Neither is routable: the host app owns its routes and its layouts, and places each one behind a page of its own.
@* Pages/Login.razor *@
@page "/login"
@attribute [AllowAnonymous]
<Card>
<h1>Sign in</h1>
<SsoLoginCard />
</Card>
SsoLoginCard renders the ways in and nothing else — no heading, no brand, no panel — so the page
keeps its own <h1> (which the router focuses on navigation) and its own chrome. It reads
returnUrl and ssoError from the query string itself; ReturnUrl and SsoButtonLabel are
parameters when the app wants to decide either.
@* Pages/SignInCallback.razor — its route must match CallbackRoute *@
@page "/signin-callback"
@attribute [AllowAnonymous]
<SsoCallback />
Both routes must be reachable without authentication. On a front end whose pages are [Authorize]
by default, that is an [AllowAnonymous] on each — a callback route behind the router's guard
redirects the completed handshake to the login page it just came from.
What the operator sees
Mode |
The card offers |
|---|---|
sso |
The SSO button alone. |
local |
The credential form alone, as the page's primary action. |
both |
The SSO button, then the form under a rule, framed as break-glass. |
Every failure the API can redirect back with has one sentence written for the person reading it — including F3's not an operator, which says exactly that rather than implying the sign-in broke. A reason this package does not recognise gets the general sentence: nothing from a query string reaches the screen. Each of them leaves the break-glass form usable underneath, which is the point of putting them on that page.
The token store, and surviving a page reload
AddYabbaDeckSsoTokenStore() registers SsoTokenStore — the token in memory, mirrored into the
browser's sessionStorage — and it must be a singleton. IHttpClientFactory resolves its
delegating handlers in their own DI scope, so a scoped store hands the bearer handler a different
instance than sign-in wrote to, and the Authorization header silently never attaches. (An app with
its own store still implements ISsoTokenStore instead and registers that, as a singleton.)
Registering the store persists the token; nothing reads it back until you call
RestoreSsoSessionAsync(). Call it between Build() and RunAsync(), as above. Reading browser
storage is a JS interop round trip and therefore asynchronous, while the router evaluates
authorization on its first render — restore any later than this and the operator watches the login
page appear and the page they wanted replace it.
The trade-off, stated
A token in browser storage is readable by any script on the origin, which matters for a console holding administrative access. It is accepted because memory-only was worse in practice: every reload and every deep link dropped the operator on the login page, and there is no silent re-authentication behind it to make that cheap. Three things bound the exposure, and none is incidental:
sessionStorage, neverlocalStorage— scoped to the one tab, gone when it closes. A test pins the JS identifiers, because swapping them is a one-word edit nothing else would notice.- Only the access token is stored, which lives an hour. No refresh token, no credential.
- The expiry is stored with it and re-checked on restore, so a token that lapsed while the tab was closed is dropped rather than spent on a round of 401s.
An app that wants a different trade-off registers its own ISsoTokenPersistence before calling
AddYabbaDeckSsoTokenStore(); a no-op implementation restores memory-only behaviour exactly.
Signing out is asynchronous, and must be awaited
ISsoSignInService.SignOutAsync() and ISsoTokenStore.ClearAsync() clear browser storage as well as
memory. An operator who signs out and immediately closes or reloads the tab would otherwise leave a
live token behind for the next load to restore — signing a signed-out operator back in. Persistence
lives in the store rather than in the sign-in service for the same reason: a console's own user
menu clears the store directly and never passes through the service.
Styling
The sign-in surface inherits the host app's look through its --yd-* design tokens rather than
importing a second one. Both consoles are framework-free; nothing here reintroduces one.
One trap worth knowing if you restyle it: Blazor's CSS isolation stamps a component's [b-…]
attribute only on elements written in that component's own markup, so a rule in a .razor.css never
reaches the <input> that InputText renders — the control silently falls back to the browser
default. Form controls are styled globally; a scoped sheet may place a control but must not skin it.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Authorization (>= 10.0.5)
- Microsoft.AspNetCore.Components.WebAssembly (>= 10.0.5)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Http (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.