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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="YabbaDeck.Sso.Web" Version="2.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="YabbaDeck.Sso.Web" Version="2.1.0" />
                    
Directory.Packages.props
<PackageReference Include="YabbaDeck.Sso.Web" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add YabbaDeck.Sso.Web --version 2.1.0
                    
#r "nuget: YabbaDeck.Sso.Web, 2.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package YabbaDeck.Sso.Web@2.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=YabbaDeck.Sso.Web&version=2.1.0
                    
Install as a Cake Addin
#tool nuget:?package=YabbaDeck.Sso.Web&version=2.1.0
                    
Install as a Cake Tool

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

  1. The operator clicks Sign in with Synology; the browser leaves for the API's challenge path.
  2. The API runs the whole OIDC handshake — it holds the client secret, and the browser never meets the identity provider.
  3. The API redirects back to this app's callback route carrying a single-use code, never a token.
  4. 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, never localStorage — 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.0 119 9/5/2026
2.0.0 104 9/4/2026
1.2.0 113 8/28/2026
1.1.0 96 8/28/2026
1.0.0 112 8/28/2026