-
-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
How the plugin is structured, why, and the contract every change follows. This
page is the public distillation of the repository's normative
docs/ARCHITECTURE.md:
the folder layout, the module dependency graph, and the extension rules below
are enforced by conformance tests (fitness functions) in the test suite, not
by convention alone. Where this page and the code disagree, the code — and the
conformance tests — win.
It complements the protocol-level Login Flow walkthrough (what an OIDC or SAML sign-in does, step by step) and the Security Model (the invariants this architecture exists to protect). The coding rules a change is reviewed against live on Coding Standards; this page is the shape, that page is the standard.
The plugin is one assembly under the root namespace
Jellyfin.Plugin.SSO_Auth (the underscore is load-bearing — it is part of the
published plugin identity and must not be renamed). Its behaviour is split into
module folders, each a namespace Jellyfin.Plugin.SSO_Auth.Api.<Module>,
and the modules form a directed acyclic graph: a module may import only the
modules declared as its allowed dependencies, and a cycle is rejected. There is
no longer a flat kernel every module reaches into — the former core was
dissolved into named modules through real dependency inversion. The whole
design favours the codebase's grain: sealed types, immutable record/variant
state, typed values over raw strings, sum types that make illegal states
unrepresentable, fail-closed defaults, and no ambient time (time comes through
an injected clock). Those properties are pinned by conformance tests and must
not regress.
Inside the single assembly:
-
Config/— the persisted configuration model (PluginConfiguration, andOidConfig/SamlConfigsharingProviderConfigBase) and its store. -
Api/— the behaviour, split into the module folders below. A module is the unit of the dependency graph. - The web binding (the
SSOController) and the composition root live in theApi/Httpmodule (see The kernel is dissolved).
Requests flow through tiers, each doing strictly less than the one above it: the
HTTP boundary (routing, model binding, headers, status mapping — nothing
else) delegates into a flow service (one linear method per login phase,
holding the security-critical call order as structure), which calls pure
leaves (validators, policies, builders, mappers — pure decisions, no I/O) over
keyed stores (state / replay / request caches, the rate limiter). The
central idea is that the most dangerous fail-closed guarantees are made
constructional rather than positional: an evidence type such as
VerifiedIdentity can only exist by having passed the gate, so "did the caller
validate the response?" becomes a compile question, not a review catch.
Modules form a DAG enforced by the conformance test
ApiModule_ImportsOnlyItsAllowedApiModules — each module is one case declaring
its allowed edges, and an import to any other Api module fails the test.
Importing non-Api namespaces (e.g. Config) is permitted. The conformance
test's edge list is the authoritative source of truth; the table below mirrors
it for orientation.
| Module | Purpose | May depend on |
|---|---|---|
Net |
Networking / SSRF / URL primitives | — (leaf) |
Secrets |
Secrets at rest (envelope, store, config wrapping) | — (leaf) |
Audit |
Append-only SSO audit logging | — (leaf) |
Authz |
Role → permission mapping | — (leaf) |
Routing |
Route-shape contract (suffix reader, path classifier) | — (leaf) |
Crypto |
Shared asymmetric signing-key strength policy (min RSA bits / approved EC curves) | — (leaf) |
LoginButtons |
Login-page button rendering + branding-sync hosted service (#722) | — (leaf) |
Logout |
Single Logout session-state store (#727) | — (leaf) |
Avatar |
Avatar fetch + SSRF-gated validation |
Net, RateLimit
|
RateLimit |
Login throttling (buckets, gates, keys) | Net |
Provider |
Provider config / naming / test-result |
Net, RateLimit
|
Linking |
Account link resolution / adoption / revocation |
Audit, Provider, RateLimit
|
Identity |
The protocol-validated identity keystone |
Authz, Provider
|
Session |
Session mint + login outcomes + SSO-only |
Authz, Avatar, Linking
|
Saml |
SAML core, validators, caches, metadata |
Authz, Identity, RateLimit, Session
|
Oidc |
OIDC flow, discovery, id_token, state |
Authz, Avatar, Identity, Net, Provider, RateLimit, Routing
|
Flows |
Per-protocol login orchestration services |
Audit, Identity, Linking, Net, Oidc, Provider, RateLimit, Saml, Session, Shared
|
Shared |
Shared served-page / flow-response helpers |
Avatar, Linking, RateLimit, Routing, Session
|
Http |
The web boundary: SSOController, request helpers, the admin test-connection probe |
the composition top — fronts every flow (wide by design); nothing imports it back |
Saml and Oidc are sibling protocol modules: neither imports the other.
Dependencies point into the low-level leaves (Net, Secrets, Audit,
Authz, Routing, Crypto), never out of them. Http is the single
composition boundary at the top of the DAG; nothing imports it back. Additional
feature modules follow the same discipline — LoginButtons (the login-page
"Sign in with …" buttons) and Logout (single-logout) each declare their own
allowed edges in the conformance test.
There is no flat Api kernel. Every type lives in a named module subfolder,
and a conformance test —
FlatApi_HoldsNoSourceFiles_EveryApiTypeLivesInAModule — fails if any source
file appears directly in Api/.
Getting here (the module split and its finale) required real dependency inversion, not a mechanical move: the former kernel's entanglements were broken by extracting shared contracts so the arrows point one way. In particular:
- the protocol-validated identity became the neutral
Identitykeystone (VerifiedIdentity), constructed by theOidc/Samlvalidators through a factory rather than referencing their types; - the session/login-result types became the
Sessionmodule; - the served-page and rate-limit-gate helpers became
Shared; - the route-shape primitives became the
Routingleaf; - the per-protocol login services became
Flows; - and the web boundary — the controller, its request helpers, and the admin
test-connection probe — became the
Httpmodule.
The SSO-managed authentication-provider id the controller once owned is now a
pinned literal (SsoManagedProviderId), decoupled from the controller's type
location so Http could move without touching persisted accounts.
This corrects an earlier version of this page, which claimed the top-level module split was "deliberately not pursued." It was pursued and is done (issues #777 and its #807 finale); the flat
Apiroot is empty and locked that way by a conformance test.
Both protocols follow the same three-phase shape — challenge (redirect to the identity provider), callback (render the intermediate auth page), and auth (mint the session) — with SAML validation done inline rather than through a separate client library. Named by the types involved, not by file location:
OpenID Connect. The challenge endpoint prepares the request (discovery +
PKCE S256), registers the in-flight authorize state in OidcStateStore, binds
that state to the initiating browser via AuthorizeStateBinding, and redirects.
The provider's callback peeks the pending state (non-consuming), exchanges the
code and validates the id_token signature, runs the RFC 9207 issuer mix-up
check (OidcResponseIssuer), derives username/roles/admin/folders from the
claims (OidcAuthorizeStateBuilder), and renders the auth page. The auth POST
atomically redeems the state (OidcStateStore.TryRedeem, one-time), then enters
the shared login tail.
SAML 2.0. The challenge endpoint builds a hand-rolled SamlAuthnRequest,
registers it in SamlRequestCache for InResponseTo correlation, binds it to
the browser, and redirects. The IdP's ACS callback parses and validates the
signed response (SamlResponseLoader over the SAML core), checks the role
allow-list (SamlLoginPolicy), and renders the auth page. The auth POST
re-validates (a caller can skip the page and POST directly), consumes the
outstanding request with its browser-binding check (SamlRequestCache), enforces
one-time-use of the assertion id (SamlReplayCache, replay protection), derives
the claims (SamlAuthorizeStateBuilder), then enters the shared tail.
The shared tail. Both auth endpoints call one collaborator,
LoginCompletionService: resolve or create the Jellyfin account link
(CanonicalLinkService.ResolveOrCreateAsync), mint the session
(SessionMinter.MintAsync — permissions, avatar, AuthenticateDirect), and map
the result to an HTTP response. LoginCompletionService accepts only a
VerifiedIdentity, so reaching account resolution with an unvalidated response
is a compile error.
The uniform outcome. LoginOutcome is a closed, internal sum type with
exactly three cases — Success, Rejected(PublicReason), and 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. LoginStatusMapper is the single place an outcome becomes an HTTP
response, and both of its switches throw on anything unmapped — so a case added
to either enum without a mapped response fails loudly rather than falling
through.
The plugin registers its host-side services with Jellyfin's DI container through
an IPluginServiceRegistrator implementation (SsoOnlyServiceRegistrator,
#165): the boot-time SSO-only reconciliation service, the login-button manager,
and the SSRF-hardened outbound HTTP client the OpenID backchannel resolves
(#755). The per-protocol authorize/replay/request caches and the shared rate
limiter live as process-wide state owned by their flow service or the Shared
tier, and the controller itself holds no mutable static state, pinned by
Controller_HoldsNoMutableStaticState.
An earlier version of this page stated there is "no
IPluginServiceRegistratorin source." That is no longer true — the registrator exists (#165) and is how the hardened HTTP client and the hosted services reach the DI container.
The architecture-conformance tests run in dotnet test and encode the structure
as executable rules, including:
-
ApiModule_ImportsOnlyItsAllowedApiModules— the module dependency DAG above. -
EverythingLivesUnderThePluginRootNamespace— no type escapes the root. - Immutability / construction rules:
AuthorizeStates_AreImmutableVariants,SamlLoginOutcome_IsImmutable,VerifiedIdentity_IsConstructedOnlyByProtocolValidators. - Boundary rules:
Controller_DelegatesOidcFlowToTheFlowService(and its SAML / login-completion equivalents),Controller_HoldsNoMutableStaticState,Controller_NeverTouchesRawSocketsOrDns,Controller_NeverTouchesProviderLinkMaps. - Discipline rules:
MutableKeyedState_LivesOnlyInsideStoreLikeTypes,OidcAuthorizeState_IsKeyedOnUtc_NotMachineLocalTime, theIntervalGatethrottle-cursor rules,ProviderFormFieldIds_MatchOidConfigProperties.
Every new structural property is locked in as a new rule here — that is the mechanism by which the architecture stays consistent over time rather than drifting. The naming/suffix contract for the tiers, and the OO rules a new type must satisfy, live on Coding Standards; the review gate checks architecture, folder placement, and OO fit explicitly on every PR (see Review Gate).
- Coding Standards — the naming, comment, and OO rules a change is reviewed against, and the fitness functions that enforce them.
- Login Flow — the protocol-level walkthrough this page's code-map complements.
- Security Model — the invariants this architecture protects.
-
docs/ARCHITECTURE.md— the in-repo normative source this page distils, with the issue references (#777, #807, #790, #791) for the migration.
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)
- Threat Model
- SSO-Only Login — design record
- Single Logout — design record
Standards & process (internal / maintainer)