Skip to content

Architecture

iderex edited this page Jul 20, 2026 · 6 revisions

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 design in one paragraph

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.

Layering

Inside the single assembly:

  • Config/ — the persisted configuration model (PluginConfiguration, and OidConfig/SamlConfig sharing ProviderConfigBase) 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 the Api/Http module (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.

The Api module map and dependency DAG

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.

The kernel is dissolved

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 Identity keystone (VerifiedIdentity), constructed by the Oidc/Saml validators through a factory rather than referencing their types;
  • the session/login-result types became the Session module;
  • the served-page and rate-limit-gate helpers became Shared;
  • the route-shape primitives became the Routing leaf;
  • the per-protocol login services became Flows;
  • and the web boundary — the controller, its request helpers, and the admin test-connection probe — became the Http module.

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 Api root is empty and locked that way by a conformance test.

The login flow, in code terms

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.

Process-wide state and DI

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 IPluginServiceRegistrator in 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.

How it is enforced (fitness functions)

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, the IntervalGate throttle-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).

See also

  • 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.

Clone this wiki locally