YabbaDeck.Sso
2.0.0
dotnet add package YabbaDeck.Sso --version 2.0.0
NuGet\Install-Package YabbaDeck.Sso -Version 2.0.0
<PackageReference Include="YabbaDeck.Sso" Version="2.0.0" />
<PackageVersion Include="YabbaDeck.Sso" Version="2.0.0" />
<PackageReference Include="YabbaDeck.Sso" />
paket add YabbaDeck.Sso --version 2.0.0
#r "nuget: YabbaDeck.Sso, 2.0.0"
#:package YabbaDeck.Sso@2.0.0
#addin nuget:?package=YabbaDeck.Sso&version=2.0.0
#tool nuget:?package=YabbaDeck.Sso&version=2.0.0
YabbaDeck.Sso
Operator sign-in for the YabbaDeck platform's management
consoles — the server half. Pair it with
YabbaDeck.Sso.Web in the Blazor front end.
An operator signs in against a Synology SSO Server over OpenID Connect, or — when that server is down, mis-registered or mid-upgrade — against a configured break-glass credential. A machine signs in with a configured client id and secret. All three end in exactly the bearer token the host already mints for itself, so nothing downstream of authentication changes.
Why there are two packages
A Blazor WebAssembly publish cannot resolve the Microsoft.AspNetCore.App shared framework for
browser-wasm, and this package needs it. So the client half is a separate package, and the two
never reference each other. A handful of small types therefore exist on both sides — the
AuthMode enum, and the exchange payloads. That is the ordinary shape of a type either side of an
HTTP boundary, not duplication to be "fixed": no single project ever loads both packages. The API
takes this one, the WebAssembly app takes the other.
The design, in one paragraph
The API is the confidential OIDC client; the browser never meets the identity provider. The API
runs the code exchange with its secret, validates the token, checks the operator against a
default-deny allow-list, and only then mints its own bearer token. The token is handed to the SPA
through a single-use code redeemed over POST — never in a URL fragment or query string.
What the server exposes
builder.Services.AddYabbaDeckSso(builder.Configuration);
builder.Services.AddSingleton<IOperatorTokenIssuer, YourTokenIssuer>();
...
app.UseAuthentication();
app.MapYabbaDeckSso();
GET /api/auth/sso/challenge?returnUrl=… |
Starts the handshake. returnUrl must be a path inside your front end; anything that could name another host falls back to /. |
GET {CallbackPath} |
The provider's redirect. Handled by the authentication middleware — nothing is mapped for it, but it must match the registered redirect URI exactly. |
POST /api/auth/sso/exchange |
{ "code": "…" } in; token, username and expiry out. A code redeems once, within CodeLifetimeSeconds. |
POST /api/auth/local/login |
{ "username": "…", "password": "…" } in; the same token, username and expiry out. The break-glass door — see below. |
POST /api/auth/service/token |
{ "clientId": "…", "clientSecret": "…" } in; the same token, a display name and expiry out. The machine door — see below. |
All four mapped endpoints are anonymous, which is what keeps sign-in reachable on a host that locks every endpoint by default. They are three distinguishable paths on purpose: an access log says which door was used without anybody decoding a token.
Your host mints the token. IOperatorTokenIssuer is not implemented here: a completed handshake
ends in exactly the token your API was already issuing, so your bearer validation, your client's
token handling and your existing tests are untouched by any of this.
Who is an operator
Signing in proves who somebody is. It does not prove they run your console — every account on the identity provider can complete the handshake, and neither console has a role floor underneath to catch the difference: a token here is full operator access.
So membership is a separate, default-deny check, evaluated after the handshake validates and before any token is minted. An empty or absent allow-list admits nobody. A deployment that configures no one has no operators, which is the safe end of that decision and the only one this library will make for you.
"AllowList": {
// Subjects are the better key: opaque, stable, and not reassignable to a different person.
"Subjects": [ "1a2b3c…" ],
// Addresses are the convenient one. Matched case-insensitively; subjects are not.
"Emails": [ "operator@example.home" ]
}
Register your own IOperatorAuthorizer before calling AddYabbaDeckSso to decide differently — on a
group claim, say, if your provider emits one.
Break-glass
The way back in when the SSO server is down, the client registration is wrong, or the NAS is mid-upgrade. A permanent, deliberate feature, not a stub credential kept behind a flag — and the one path in this library that reaches nothing outside your own process. No discovery, no back-channel call, no database. A break-glass door that depends on the thing it exists to survive is not one.
There is no default credential. Username and PasswordHash have no defaults and never will, so
a deployment that configures nothing has no local account rather than a famous one. Configure the
password as a hash — the plaintext is never a setting:
// Run once, paste the result into Auth:Operator:Local:PasswordHash.
Console.WriteLine(LocalOperatorPassword.Hash(password));
The format is ASP.NET Core Identity's PasswordHasher<T> — PBKDF2-HMAC-SHA512, salted, versioned,
carrying its own work factor — the same format the platform hashes app-user passwords in.
A mode that offers the local credential must have one: local or both with either key missing
fails startup, with the key named in the message. The alternative is a login form that can never be
satisfied, discovered on the day it is needed.
The allow-list does not gate this path, deliberately. Membership answers a question only SSO raises — the identity provider vouches for everyone with an account on it, so somebody it vouched for still has to be shown to be an operator here. The break-glass credential raises no such question: possessing it is the grant. Gating it would also mean the safe default — an empty allow-list, admitting nobody — silently deletes break-glass, leaving a deployment whose allow-list is what locked everyone out with no way back in at all.
The operator signs in under a subject of its own, local:<username>, so a break-glass token is
recognisable as one wherever its subject lands and no configured subject can be crafted to collide
with it.
| Wrong username or password | 401, one answer for both — and the same one for a deployment with no credential configured. |
| Too many failures | 429 with Retry-After. |
Mode is sso |
400. The endpoint is mapped in every mode so a bookmarked login page gets a sentence rather than a 404. |
Throttling is keyed per caller, never per account. There is exactly one local credential, so a
counter attached to it would be a counter anyone on the network can drive: a handful of wrong guesses
from anywhere and the emergency door is shut, at the moment it is needed. The lockout also expires
by itself after LockoutMinutes — a state only an administrator can clear is useless in a console
no administrator can currently sign in to. Behind a reverse proxy the caller is only the real client
once forwarded headers are enabled, which the deployment notes already call for.
Every local sign-in is logged at Warning, saying that SSO was bypassed. Use of the emergency
door should be visible to somebody skimming the log for problems, not only to somebody who went
looking for it. Failed attempts and lockouts are logged at Warning too; the password never is.
Machine callers
Consoles get called by tools as well as by people. A provisioning tool that registers apps has to hold a token, and neither door above was built for it: it completes no handshake, appears on no allow-list, and there is nobody at a keyboard to type an emergency password.
So there is a third door, configured outside Auth:Operator — a machine is not an operator, and
its credential has to be separately configurable, separately rotatable and separately removable. It is
withdrawn by deleting one entry, which touches neither SSO nor break-glass.
"Auth": {
"Service": {
// Keyed by client id, so the id is the key rather than a repeated value.
// Empty means nobody — which is the default, and how this door is closed everywhere
// that has not deliberately opened it.
"Clients": {
"yabbaprov": {
"SecretHash": "…", // LocalOperatorPassword.Hash(…) — the raw secret is never a setting
"DisplayName": "YabbaProv" // optional; the client id is used without one
}
}
}
}
The secret is hashed in the same format, by the same function as the break-glass password, so a host's "generate a hash" command produces both and there is one answer to "what is a credential here".
A client named with a blank SecretHash fails startup, naming the key. Configuring no clients
stays perfectly ordinary — that is how this door is closed — but naming one and leaving its secret
empty is a half-written configuration that admits nobody and answers 401 in the same words as a
wrong secret. Correct for the caller, useless for whoever is deploying. The same asymmetry the
break-glass check draws, and learned the same way: a deploy pipeline that did not pass the hash
through left both environments refusing a machine that was configured perfectly on its own side.
| Unknown client id, wrong secret, or no clients configured | 401, one answer for all three — a distinct "no such client" would enumerate the machines this console admits. |
| Too many failures | 429 with Retry-After, per calling address, in a bucket of its own — a misconfigured tool retrying a wrong secret must not shut the emergency door for a human on the same host. |
Auth:Operator:Mode |
Not consulted. How the people who run a console sign in says nothing about whether a machine may call it. |
| The allow-list | Not consulted either, on the break-glass reasoning one step further: a machine is not an OIDC identity, so there is no subject to key it on, and possessing the secret is the grant. |
The machine signs in under service:<clientId>, a third subject namespace beside local: and the
provider's own, so what a token is and where it came from stays legible wherever the subject lands.
Every issued token is logged at Information with the client id — not Warning like break-glass,
because this is the ordinary way a machine works and a warning per run teaches people to stop reading
them.
What this buys is credential scope, not authorization scope. A service token carries the same access an operator's does, because neither console has a role floor to scope it against. What is separate is the credential: its own secret, its own rotation, its own subject in the log, and its own line in configuration that can be removed without locking a human out. Per-client authorization belongs with the day a console grows roles, and would arrive as a claim on the identity rather than as a second kind of token.
When sign-in fails
Every failure — an unreachable provider, a refused code exchange, a denied consent, a token that does
not validate, a replayed code — lands the operator on WebLoginPath with a reason, never on a 500
and never on a blank page. Including a provider that is already down when the operator clicks the
button: the handler fetches discovery to build the authorization URL, so that one fails before the
browser goes anywhere and before there is any callback for OnRemoteFailure to fire on. It is caught
at the challenge endpoint instead — which matters more than the rest of the table put together, since
a provider being down is the state break-glass exists for, and the way to break-glass is that login
page. The way out of a broken identity provider is the local form on that page,
so getting there is the feature.
?ssoError= |
Means |
|---|---|
sso_disabled |
This deployment is configured for the local credential only. |
handshake_failed |
The handshake did not complete. One reason for every cause on purpose: the operator can act on none of the distinctions, and the log carries the detail. |
access_denied |
The operator, or the provider on their behalf, refused. |
no_subject |
The provider vouched for someone with no subject claim. |
not_an_operator |
They signed in correctly and are not on this deployment's allow-list. Says nothing about who is. |
The client secret is redacted out of anything logged from a failure: the provider's response text is an echo of a request that carried it.
Configuration
{
"Auth": {
"Operator": {
// sso | local | both — case-insensitive.
"Mode": "both",
"Sso": {
// Synology's issuer is a path, not a host root. Carry it whole.
"Authority": "https://admin.example.home/webman/sso",
"ClientId": "…",
"ClientSecret": "…", // server-side only; never reaches the browser
"CallbackPath": "/signin-oidc", // must match the registered redirect URI exactly
"Scopes": "openid email", // no `profile` — Synology does not advertise it
"WebBaseUrl": "https://backoffice.example.home",
"WebCallbackPath": "/signin-callback",
"WebLoginPath": "/login",
"CodeLifetimeSeconds": 60 // the hand-off code, not the token
},
"AllowList": {
// Empty means nobody. There is no safe default for who runs a console.
"Subjects": [],
"Emails": []
},
"Local": {
// No defaults. A deployment that configures nothing has no local account.
// Required once Mode is `local` or `both` — startup fails naming the key.
"Username": "…",
"PasswordHash": "…", // LocalOperatorPassword.Hash(…); the raw password is never a setting
"MaxFailedAttempts": 5, // per calling address, not per account
"LockoutMinutes": 15 // and it lifts by itself
}
},
// Outside Operator, and independent of its Mode: machines are not operators.
"Service": {
"Clients": {
// Empty means nobody. See "Machine callers" above.
}
}
}
}
Bind and validate it in the host's composition root:
builder.Services.AddYabbaDeckSsoOptions(builder.Configuration);
Each section is bound and validated as its own options type, because ValidateDataAnnotations() does
not recurse — annotations on a nested object would never run.
Only settings that hold in every mode are annotated [Required]. A local deployment configures
no identity provider and must not fail startup over an Authority it does not use. The
mode-dependent half of the check lives in AddYabbaDeckSso, which knows the mode: sso needs an
authority and a client, local and both need a credential, and every mode needs WebBaseUrl —
without a front-end origin even the "SSO is switched off here" redirect lands in the API's own 404.
Each throws while the service collection is being built, naming the missing key.
Deploying against Synology
Behaviour recorded against a live Synology SSO Server, and the reason several defaults above look the way they do:
| Synology behaviour | What the client must do |
|---|---|
Discovery advertises no response_modes_supported; form_post is rejected |
response_mode=query |
scopes_supported contains no profile |
scopes stay openid email |
Emits a non-standard username claim instead of preferred_username |
read both, fall back to email then sub |
| The issuer is a path, not a host root | the authority carries the path |
| An internal CA signs the SSO host | bake that CA into the API image's trust store, or the back-channel call fails UntrustedRoot |
The redirect URI must be https |
terminate TLS in front and enable forwarded headers |
Register one confidential client per app per environment, each with its own redirect URI. A rotated or leaked secret then reaches exactly one stack, and a redirect URI can never be satisfied by the wrong one.
| 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.Authentication.OpenIdConnect (>= 10.0.5)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.16.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.