Skip to content

Login Flow

iderex edited this page Jul 20, 2026 · 3 revisions

Login Flow

This page walks through what actually happens, end to end, when someone signs in to Jellyfin through this plugin — from the moment they click a provider button to the moment Jellyfin hands them a session. It describes both protocols, OpenID Connect and SAML 2.0, as they are implemented today, so you can follow the flow without reading the source. Where a step exists to keep the login path fail-closed, this page says so and links to the Security Model, which is the authoritative place for why each check is there.

If a step here disagrees with the code, the code wins — please open an issue.

The shape both flows share

OpenID and SAML differ in the messages on the wire, but the plugin drives them through the same four stages:

  1. Challenge — the browser asks the plugin to start a login for a named provider. The plugin builds the protocol-specific request and redirects the browser to the identity provider (IdP).
  2. Identity provider — the user authenticates at the IdP (password, MFA, consent — entirely the IdP's concern). The IdP sends the browser back to the plugin's callback with a signed result.
  3. Callback — the plugin validates that result fail-closed and, if it passes, renders a small auth page that runs in the browser.
  4. Session mint — the auth page posts the validated result back to the plugin, which resolves the Jellyfin account, applies the granted permissions, and asks Jellyfin to issue a session token. The browser stores the token and lands in the web UI, signed in.

The split between stage 3 (callback) and stage 4 (session mint) exists because the browser redirect that returns from the IdP cannot carry the client's device identity. The auth page bridges that: it first bootstraps the Jellyfin client credentials, then makes the final, authenticated call that actually creates the session.

All the anonymous endpoints below — challenge, callback, and the session-mint call — sit behind the optional per-client rate limiter.

OpenID Connect

sequenceDiagram
    participant B as Browser
    participant P as SSO plugin
    participant I as Identity provider

    B->>P: GET /sso/OID/start/{provider}
    Note over P: rate-limit check<br/>PKCE-S256 discovery gate<br/>prepare login (PKCE, state, nonce)<br/>store authorize state (provider-bound)
    P-->>B: 302 to IdP authorize URL
    B->>I: authenticate (login / MFA / consent)
    I-->>B: 302 to /sso/OID/redirect/{provider}?code&state&iss
    B->>P: GET callback
    Note over P: look up state (unexpired, same provider)<br/>redeem code, validate id_token<br/>(signature / iss / aud / exp)<br/>RFC 9207 iss match<br/>resolve stable sub, apply role allow-list
    P-->>B: auth page (HTML) if the login is permitted
    Note over B: bootstrap Jellyfin credentials + device id
    B->>P: POST /sso/OID/Auth/{provider} { data: state, device info }
    Note over P: claim state (single-use)<br/>resolve canonical link on sub<br/>create / adopt account<br/>apply permissions, mint session
    P-->>B: AuthenticationResult (access token)
    Note over B: store token, redirect to /web
Loading

1. Challenge — GET /sso/OID/p/{provider} (or /sso/OID/start/{provider})

  • Optionally throttled by the rate limiter.
  • PKCE downgrade gate (#141). The plugin always sends a PKCE code_challenge with the S256 method, but PKCE only protects against authorization-code injection if the server enforces it. So the provider's discovery document is fetched and checked for S256 in code_challenge_methods_supported. By default a provider that does not advertise it logs an [SSO Audit] warning and the login proceeds; with Require PKCE (S256) (RequirePkce) set on the provider, the login is instead refused when S256 is not advertised, or when discovery cannot be read to confirm it. The result is cached briefly so a login does not fetch discovery every time. See PKCE downgrade detection.
  • The plugin prepares the login (this generates the PKCE verifier, the CSPRNG state token, and the nonce) and builds the redirect URI back to its own callback.
  • The in-flight authorize state is stored keyed by that state token, tagged with the provider that minted it and whether this is a linking request. The store is bounded and provider-bound; see OpenID authorize state.
  • The plugin redirects the browser to the provider's authorize URL.

2. Identity provider

The user authenticates at the IdP. Nothing on the plugin side runs during this step; the authorize state simply waits (for up to about 15 minutes, long enough for an MFA or consent step). The IdP then redirects the browser back to the callback with an authorization code, the original state, and — from providers that implement RFC 9207 — an iss parameter.

3. Callback — GET /sso/OID/r/{provider} (or /sso/OID/redirect/{provider})

  • The state query value must match a stored authorize state that is not expired and was minted for this same provider; otherwise the callback is rejected. This stops a state issued for one provider from being validated on another's callback.
  • The plugin redeems the authorization code for tokens. The returned id_token is validated fail-closed before any identity is read from it: its signature is verified against the provider's published JWKS, and its issuer, audience/azp, and lifetime are checked. Only asymmetric signature algorithms are accepted (RS/PS/ES 256/384/512); a symmetric (HS*) or unsigned (alg: none) token is rejected regardless of configuration. See OpenID id_token validation.
  • RFC 9207 mix-up check. If the authorization response carried an iss parameter, it must name the same issuer as the validated id_token; a mismatch means the response came from a different authorization server than the one the token belongs to, and the login is rejected. See authorization-response issuer.
  • Stable subject (#155). The login must resolve an OpenID sub claim, which is the stable identifier the account link is keyed on. sub is a required OIDC claim, so a missing one means a non-conformant provider and the login is refused rather than falling back to the mutable username.
  • The user's roles and the provider's allow-list decide whether the login is permitted and what it grants (see Role and permission mapping below). A permitted login's derived values (username, admin, Live TV, folders, avatar URL) are stored on the authorize state.
  • If the login is permitted, the plugin returns the auth page (a small HTML document, served with hardening headers and a strict Content-Security-Policy — see Response hardening). If not, it returns a uniform denial that does not reveal why.

4. Session mint — POST /sso/OID/Auth/{provider}

The auth page runs in the browser and:

  • loads the Jellyfin web app in a hidden, sandboxed iframe to bootstrap the client credentials and device id in localStorage;
  • posts { data: <state token>, deviceId, appName, appVersion, deviceName } back to the session-mint endpoint.

The plugin then:

  • looks the state up by that token and atomically claims it (removes it), so one state mints at most one session even under concurrent posts;
  • resolves the canonical account link on the sub (see Account linking below), creating or adopting the Jellyfin account as policy allows;
  • applies the granted permissions, optionally fetches the avatar (SSRF-guarded — see avatar fetch), and asks Jellyfin to authenticate the client, recording the client's source IP the same way a password login does;
  • writes an [SSO Audit] success entry and returns Jellyfin's AuthenticationResult.

The auth page stores the returned access token in localStorage and redirects to /web/index.html, now signed in.

SAML 2.0

sequenceDiagram
    participant B as Browser
    participant P as SSO plugin
    participant I as Identity provider

    B->>P: GET /sso/SAML/start/{provider}
    Note over P: rate-limit check<br/>build AuthnRequest<br/>remember request id (solicited-only, opt-in)
    P-->>B: 302 to IdP SSO URL (SAMLRequest)
    B->>I: authenticate (login / MFA / consent)
    I-->>B: POST SAMLResponse to /sso/SAML/post/{provider}
    B->>P: POST callback (form SAMLResponse)
    Note over P: parse (DTD prohibited), validate signature<br/>time bounds, audience, recipient<br/>apply role allow-list
    P-->>B: auth page (HTML) if the login is permitted
    Note over B: bootstrap Jellyfin credentials + device id
    B->>P: POST /sso/SAML/Auth/{provider} { data: response XML, device info }
    Note over P: InResponseTo correlation (opt-in)<br/>one-time-use replay check<br/>resolve canonical link on NameID<br/>create / adopt account, mint session
    P-->>B: AuthenticationResult (access token)
    Note over B: store token, redirect to /web
Loading

1. Challenge — GET /sso/SAML/p/{provider} (or /sso/SAML/start/{provider})

  • Optionally throttled by the rate limiter.
  • The plugin builds a SAML AuthnRequest for the configured SP entity id, with its own assertion-consumer URL as the reply address. A linking request sets RelayState to linking.
  • Solicited-only mode (#156, opt-in). With ValidateInResponseTo set, the plugin remembers the request's id (scoped by provider) so the callback can later require the response's InResponseTo to match a request this server actually issued. This is registered for login flows only, not for the linking flow.
  • The plugin redirects the browser to the IdP's SSO URL with the encoded SAMLRequest.

2. Identity provider

The user authenticates at the IdP, which then POSTs a signed SAMLResponse back to the plugin's assertion-consumer URL (SAML's HTTP-POST binding — this is why the callback is a POST, unlike OpenID's redirect).

3. Callback — POST /sso/SAML/p/{provider} (or /sso/SAML/post/{provider})

The SAMLResponse form field is parsed and validated fail-closed. The response is accepted only when every check holds; any failure returns a uniform rejection:

  • Bounded input — an oversized Base64 body is rejected before any decoding.
  • XML parsing — DTD processing is prohibited, so any <!DOCTYPE> is rejected up front (this closes XXE and entity-expansion attacks).
  • Signature — exactly one signature, covering the element that is actually consumed (this blocks XML-signature-wrapping); SHA-1 is rejected.
  • Time boundsNotOnOrAfter (and NotBefore when present) are enforced with a bounded clock skew; a missing or unparseable bound is rejected.
  • Audience — the assertion's AudienceRestriction must name this service provider (opt-out available).
  • Recipient/Destination (#156, opt-in) — with ValidateRecipient set, the signed Recipient (or Response Destination) must equal one of this server's assertion-consumer URLs, so an assertion minted for a different endpoint cannot be presented here.

See SAML response validation for the detail. If the assertion's roles satisfy the login allow-list, the plugin returns the auth page carrying the signed response; otherwise it returns a uniform denial.

4. Session mint — POST /sso/SAML/Auth/{provider}

As on the OpenID side, the auth page bootstraps the client credentials and posts the response back. The session-mint endpoint re-validates the response (it never trusts that the page ran the checks — a caller can POST straight here and skip the page) and then:

  • Solicited-only correlation (#156). With ValidateInResponseTo set, the response's InResponseTo is consumed against a request this server issued; an unsolicited (IdP-initiated) response carries none and is refused, and a matching id is one-time-use.
  • Login allow-list is enforced here too.
  • One-time use (#219). The consumed assertion id is claimed against a per-provider replay cache, so a captured assertion cannot be replayed to mint a second session.
  • NameID (#95). The assertion must resolve a usable NameID; SAML keys the account link directly on it (it is already the stable identifier — key and account name are the same value).
  • The plugin resolves the canonical account link on the NameID, applies the role-derived permissions, writes an [SSO Audit] success entry, and asks Jellyfin to authenticate the client.

Role and permission mapping

On both protocols the user's roles/groups — OpenID role claims, or the SAML Role attribute — are compared against per-provider allow-lists to decide the login and its privileges. Matching is exact (ordinal) and grants are additive (a role can grant a privilege, never take one away):

Grant Configuration Effect
Login Roles When set, the user must carry at least one listed role to sign in. When empty, any authenticated user is allowed (RBAC off).
Administrator AdminRoles A listed role grants Jellyfin administrator rights.
Library folders EnableFolderRoles + folder-role mapping, or the statically EnabledFolders set Grants access to specific libraries.
Live TV EnableLiveTvRoles + LiveTvRoles / LiveTvManagementRoles Grants Live TV access and/or management.

On OpenID, the username is taken from the preferred_username claim (or a configured DefaultUsernameClaim), falling back to sub; on SAML it is the NameID. The optional avatar URL is built from claim values against a configured format. All of these permission grants are only written to the account when the provider's EnableAuthorization is on; with it off, the plugin authenticates the user but does not manage their permissions.

Account linking

An SSO login is bound to the IdP's stable identifier — OpenID sub / SAML NameID — recorded per provider as a canonical link to a Jellyfin user id. On each login the plugin:

  • resolves the link by that stable key. If a link exists, that account is used;
  • otherwise, if a Jellyfin account with the same name already exists but is unlinked, the login is refused (HTTP 403) by default — a same-named account is not silently adopted — unless the provider's AllowExistingAccountLink is enabled, in which case the account is adopted and linked;
  • otherwise a new account is created with a random password and linked.

The write is atomic under concurrency, so two simultaneous first logins for the same identity converge on one account rather than both creating one. This is the core anti-account-takeover control; see Identity binding.

Self-service linking

A signed-in user can link an SSO identity to their existing Jellyfin account from /SSOViews/linking without an administrator. The linking variant runs the same challenge → IdP → callback path (RelayState = linking on SAML; isLinking on OpenID), but the auth page calls POST /sso/{mode}/Link/{provider}/{jellyfinUserId} with the user's own token instead of minting a session. The endpoint requires the caller to be that user (or an admin), and the SAML linking callback enforces the same one-time-use replay check as a login. An administrator can later revoke a user's SSO access with POST /sso/Unregister/<username>, which removes that user's canonical links across every provider.

Non-web clients and Quick Connect

The redirect-and-auth-page flow above runs in a web browser — the Jellyfin Web UI. A native client (mobile or TV app) that cannot run the callback page completes SSO through Jellyfin's own Quick Connect instead: the client displays a Quick Connect code, the user signs in through SSO in a browser and approves that code, and Jellyfin hands the authenticated session back to the waiting client. Quick Connect is Jellyfin's built-in mechanism; the plugin's role is simply to make the browser login SSO-capable. A native client that supports neither the browser flow nor Quick Connect cannot complete an SSO login — use the Web UI or a Quick Connect–capable client.

See also

  • Security Model — why each validation step is there, and how the login path fails closed.
  • Architecture Internals — the as-built code-fact map: the concrete types and methods each step above runs through, and where they live.
  • Provider Setup — per-identity-provider setup, including the id_token requirements.
  • Troubleshooting — what a specific rejection at any of these steps means.

Clone this wiki locally