-
-
Notifications
You must be signed in to change notification settings - Fork 1
Architecture Internals
A map for a new contributor working from a fork, a source tarball, or offline —
so a first change can be placed onto the flow instead of reverse-engineered
from SSO-Auth/Api/Http/SSOController.cs (currently ~1130 lines) and its ~45
helper types. It complements, and does not replace, the narrative
Login Flow walkthrough and the
Architecture coding standard: this page is the as-built
code-fact map (concrete types, methods, and file locations), the
Architecture page is the standard and target the code is
converging on, and Login Flow is the protocol-level walkthrough.
This page keeps two things apart on purpose:
- Current state — what is in the tree today, verified against the code at the time of writing. Every type and method named below exists.
- Target direction (#318) — the unified OO architecture the codebase is migrating toward, one small behaviour-preserving PR at a time. It is marked as such everywhere it appears; nothing here describes the target as if it already existed.
Both protocols follow the same shape — challenge (redirect to the identity provider), callback (render an intermediate auth page), auth (mint the session) — with the SAML validation done inline rather than through a separate client library.
OidChallenge (GET OID/p/{provider}, OID/start/{provider})
-> PrepareLoginAsync (Duende OidcClient) -- discovery + PKCE (S256) check
-> OidcStateStore.TryAdd -- registers the in-flight authorize state
-> AuthorizeStateBinding -- binds the state to the initiating browser (cookie)
-> redirect to the provider
OidCallback (GET OID/r/{provider}, OID/redirect/{provider}) -- the provider's callback
-> OidcStateStore.PeekCurrent -- looks up the pending state (does not consume it)
-> OidcClient.ProcessResponseAsync -- token exchange + id_token signature validation
-> OidcResponseIssuer.IsRejected -- RFC 9207 issuer mix-up check
-> OidcAuthorizeStateBuilder.Build -- derives username/roles/admin/folders from claims
-> renders the intermediate auth page (WebResponse.Generator)
OidAuth (POST OID/Auth/{provider}) -- the auth page posts back here to mint the session
-> OidcStateStore.TryRedeem -- one-time atomic claim of the authorize state
-> CanonicalLinkService.ResolveOrCreateAsync -- resolve/adopt/create the Jellyfin account link
-> SessionMinter.MintAsync -- permissions + avatar + AuthenticateDirect
-> LoginOutcome -> LoginStatusMapper.ToActionResult
SamlChallenge (GET SAML/p/{provider}, SAML/start/{provider})
-> SamlAuthnRequest -- hand-rolled AuthnRequest (SamlAuthnRequest.cs)
-> SamlRequestCache.Register -- outstanding-request correlation (InResponseTo)
-> AuthorizeStateBinding -- binds the request to the initiating browser (cookie)
-> redirect to the IdP
SamlCallback (POST SAML/p/{provider}, SAML/post/{provider}) -- the IdP's ACS callback
-> SamlResponseLoader.TryParse -- parses + validates the signed response (SamlResponse.cs core)
-> SamlLoginPolicy.IsLoginAllowed -- role allow-list check
-> renders the intermediate auth page (WebResponse.Generator)
SamlAuth (POST SAML/Auth/{provider}) -- the auth page posts back here to mint the session
-> SamlResponseLoader.TryParse + validation (again -- a caller can skip the page and POST directly)
-> SamlRequestCache.TryConsume -- InResponseTo correlation + browser-binding check
-> SamlReplayCache -- one-time-use assertion ID (replay protection)
-> SamlAuthorizeStateBuilder.Build -- derives admin/roles/folders from assertion attributes
-> CanonicalLinkService.ResolveOrCreateAsync
-> SessionMinter.MintAsync
-> LoginOutcome -> LoginStatusMapper.ToActionResult
Both auth endpoints call the identical two-step tail —
CanonicalLinkService.ResolveOrCreateAsync then SessionMinter.MintAsync —
now folded into one shared collaborator, LoginCompletionService
(Api/Flows/LoginCompletionService.cs, target-direction step 11, #318 §3 /
#497). Both flow services call it instead of duplicating the tail at each
login/link site.
A handful of state stores live as private static readonly fields — the OIDC
and SAML caches on their owning flow service (OidcLoginService /
SamlLoginService, #500/#501) and the shared rate limiter on the
SsoRateLimitGate (Api/Shared, #160) — not dependency-injected (there is no
IPluginServiceRegistrator in source, so a static readonly field is today's
only way to get one process-wide instance):
| Store | Owner | Guards |
|---|---|---|
OidcStateStore |
OidcLoginService |
in-flight OIDC authorize state; capacity cap, lifetime, throttled-sweep via IntervalGate
|
SamlReplayCache |
SamlLoginService |
one-time-use SAML assertion IDs (replay protection) |
SamlRequestCache |
SamlLoginService |
outstanding SAML AuthnRequest IDs for InResponseTo correlation |
SsoRateLimiter |
SsoRateLimitGate |
opt-in per-client rate limiting on the login endpoints and the link/unlink admin surface |
The controller itself now holds no mutable static state — every store above
lives in a flow service or the Shared tier, pinned by
Controller_HoldsNoMutableStaticState.
LoginOutcome (SSO-Auth/Api/Session/LoginOutcome.cs) is a closed, internal sum type
with exactly three cases: Success(AuthenticationResult), Rejected(PublicReason),
Denied. There is deliberately no Error case — anything unexpected
propagates as an exception and surfaces as a genuine 500, so a client-caused
condition can never silently become one, and a login-path helper cannot report
an ambiguous "maybe okay" result. LoginStatusMapper.ToActionResult is the
single place an outcome becomes an HTTP response; both its outer switch (over
LoginOutcome) and its inner switch (over PublicReason) throw
InvalidOperationException on anything unmapped, so a case added to either
enum without a mapped response fails loudly (a compile-time-adjacent
guarantee, not a silent fall-through). AccountLinkForbiddenException, thrown
by CanonicalLinkService.ResolveOrCreateAsync, is caught at each of the four
call sites and mapped to Rejected(PublicReason.AccountLinkForbidden) — a 403.
Four tiers, each discoverable by suffix or namespace, enforced as fitness
functions in SSO-Auth.Tests/ArchitectureConformanceTests.cs (runs in
dotnet test, so every PR is checked):
| Tier | Suffix / shape | Example | Rule |
|---|---|---|---|
| HTTP boundary |
*Controller, derives ControllerBase
|
SSOController |
Controllers_DeriveFromControllerBase |
| Flow (stateful collaborator) |
*Service, internal sealed |
CanonicalLinkService, SessionMinter
|
FlowServices_AreInternalAndSealed |
| Pure leaves |
*Validator/*Builder/*Mapper/*Resolver/*Policy/*Extractor, internal sealed or static |
LoginStatusMapper, OidcAuthorizeStateBuilder
|
SingleResponsibilityHelpers_Are… (internal, sealed-or-static) |
| Keyed runtime state | *Store/*Cache/*Gate/*Limiter |
OidcStateStore, SsoRateLimiter
|
MutableKeyedState_LivesOnlyInsideStoreLikeTypes |
All production types live under the Jellyfin.Plugin.SSO_Auth root namespace
(EverythingLivesUnderThePluginRootNamespace); helper types are internal by
default, never part of the plugin's public surface
(SingleResponsibilityHelpers_AreInternal_NotPartOfThePublicSurface) and
sealed or static leaves, never an inheritance base
(…_AreSealedOrStatic_NotAnInheritanceBase). Two call-level invariants are
locked in as source scans over every controller file rather than by
reflection: the controller touches no provider link map directly
(Controller_NeverTouchesProviderLinkMaps — that stays confined to
CanonicalLinkService and ServerManagedFields.Preserve) and no raw
socket/DNS surface (Controller_NeverTouchesRawSocketsOrDns).
Naming convention for new OpenID types (#370): spell it Oidc, not Oid.
Oid* (OidConfig, OidChallenge, OidAdd, …) survives only on the
config/endpoint surface for backward compatibility with the serialized
configuration and the existing route literals — neither is renamed by this
convention. Every extracted OpenID helper already uses Oidc*
(OidcLoginService, OidcStateStore, OidcIdTokenValidator, …); a new type
should follow that spelling so a grep for either prefix does not miss half
the OpenID surface. This is a naming convention only — no mass rename of
existing serialized field names or routes.
The #318 target-architecture design note laid out a further decomposition on top of the same four tiers, landed as a sequence of small, independently gated PRs (tracked on the SSO Roadmap board). All of it has now merged:
-
A flow-service spine per protocol —
OidcLoginService/SamlLoginServiceunder theApi/Flows/namespace, each owning its protocol's process-wide stores as its ownstatic readonlyfields (a pure relocation, not a lifetime change — no service registrator exists to promote them to DI). The controller is now route/model-binding plus one call into a flow service. -
LoginCompletionService— the shared resolve → mint → audit → outcome tail described above, extracted once so both protocols call one collaborator instead of duplicating the same four call sites. -
VerifiedIdentity— a protocol-agnostic record only the two protocol validators can construct (private constructor + factory), generalizing the earlier OIDCRedeemedState.LoginCompletionServiceaccepts only aVerifiedIdentity, so reaching account-resolution with an unvalidated response is a compile error rather than something a review has to catch — the fail-closed keystone of the migration.
All three now exist in the tree (Api/Flows/OidcLoginService.cs +
SamlLoginService.cs, Api/Flows/LoginCompletionService.cs,
Api/Identity/VerifiedIdentity.cs) and each structural property above is locked by a
fitness function in SSO-Auth.Tests/ArchitectureConformanceTests.cs, so the
architecture cannot silently regress. The ordered step list and the
maintainer decisions that drove it are in the #318 design-note comment; this
page is the map of the resulting shape so later PRs and their reviews do not have
to reconstruct it from scratch.
-
Fail closed by construction, not by convention. A missing signature, an
out-of-bounds time window, a wrong audience, a replayed assertion, an
unresolved identity, or an unmapped
LoginOutcome/PublicReasoncase is rejected or throws — never defaults to success.LoginStatusMapper's two exhaustive switches are the enforcement point; see §1. -
A throwing accessor turned into a guarded
Try*accessor is a fail-open hazard. Replacing a throw with a miss-branch (is not { } x) on the login path silently converts implicit fail-closed into fail-open unless the new miss-branch is explicitly re-decided as a rejection, with a reject-path test covering it. Every store lookup on this page (PeekCurrent,TryRedeem,TryConsume,FindOidConfig/FindSamlConfig) already follows this pattern — preserve it when extracting or refactoring around them. -
Guard order is a preserved invariant. For example, the disabled-provider
check in
OidAuth/SamlAuthruns before the state/replay consume, so a disabled provider cannot be used to burn a legitimate user's in-flight state. Reordering guards during a refactor is a behaviour change, not a pure move — it needs the same review a semantics change gets. -
Log-forging sanitization is inline, not delegated. A value that reaches
a log call and originated from the request (provider name, claims,
relayState, roles) is sanitized at the call site (x?.ReplaceLineEndings(string.Empty)), because CodeQL'scs/log-forgingsanitizers are not propagated across a helper-method boundary. Wrapping the sanitization in a shared helper defeats the analysis, not just the style. -
Secrets are never logged and never round-tripped.
OidSecretand signing keys are write-only: a config read never returns the stored secret, and no log line carries one.
| Concern | File(s) |
|---|---|
| HTTP endpoints, provider admin CRUD | SSO-Auth/Api/Http/SSOController.cs |
| OIDC state / discovery caching |
OidcStateStore.cs, PendingState.cs, RedeemedState.cs, TimedAuthorizeState.cs
|
| OIDC claim/role derivation |
OidcAuthorizeStateBuilder.cs, OidcRoleExtractor.cs, OidcResponseIssuer.cs, OidcIdTokenValidator.cs
|
| SAML core (parsing, signature, XML hardening) |
SamlResponse.cs, SamlResponseLoader.cs, SamlCertificate.cs, SamlRecipientValidator.cs
|
| SAML request/replay correlation |
SamlRequestCache.cs, SamlReplayCache.cs, SamlAuthorizeStateBuilder.cs, SamlLoginPolicy.cs
|
| Account linking (resolve/adopt/create/revoke) |
Linking/CanonicalLinkService.cs, AccountLinkResolver.cs, AdoptionEligibilityResolver.cs, CanonicalLinkRevoker.cs
|
Session minting (permissions, avatar, AuthenticateDirect) |
SessionMinter.cs, SessionParameters.cs, AvatarService.cs
|
| The uniform outcome / HTTP mapping |
LoginOutcome.cs, LoginStatusMapper.cs, PublicReason.cs
|
| Rate limiting / browser binding |
SsoRateLimiter.cs, AuthorizeStateBinding.cs, IntervalGate.cs
|
| Config persistence |
Config/PluginConfiguration.cs, ProviderConfigStore (see Config/) |
| Structural rules (fitness functions) | SSO-Auth.Tests/ArchitectureConformanceTests.cs |
- Login Flow — the wiki walkthrough this page complements.
- Security Model — the fuller threat-model-level writeup.
- #318 — the target-architecture design note this page's §2 target section summarizes.
Repository · Issues · Releases · Security policy - report vulnerabilities privately, never in a public issue. Pages describe what is implemented today; if the wiki disagrees with the code, the code wins.
Getting started
- Installation
- Provider Setup
- Hardening & Options Reference
- Migrating from 9p4
- Troubleshooting
- Rollback
How it works
Security
- Security Model
- Security Conformance (ASVS / RFC 9700)
- SSO-Only Login - design record
- Single Logout - design record
Standards & process (internal / maintainer)