Jellyfin SSO: cheeztv OIDC hosts, media-group gating, LDAP outpost #28

Merged
benvin merged 2 commits from benvin/jellyfin-sso into main 2026-08-29 11:51:20 +10:00
7 changed files with 92 additions and 31 deletions
+6 -3
View File
@@ -1,3 +1,6 @@
# Permission group akP-jellyfin-admin (name = filename). Grants admin
# access to jellyfin: bound to the jellyfin application and mapped to its admin role.
application: jellyfin
# Permission group akP-jellyfin-admin (name = filename). Does NOT gate the
# jellyfin application (SSO access is gated by the media groups akP-media-fafflix
# / akP-media-cheeztv). It exists purely as an admin role-claim group: the
# jellyfin-plugin-sso matches the hierarchical `ak_groups` claim against this
# group name to grant Jellyfin administrator rights. Nested under akR-global-admin.
attributes: {}
+6 -3
View File
@@ -1,3 +1,6 @@
# Permission group akP-jellyfin-user (name = filename). Grants user
# access to jellyfin: bound to the jellyfin application and mapped to its user role.
application: jellyfin
# Permission group akP-jellyfin-user (name = filename). Does NOT gate the
# jellyfin application (SSO access is gated by the media groups akP-media-fafflix
# / akP-media-cheeztv). It exists purely as a user role-claim group: the
# jellyfin-plugin-sso matches the hierarchical `ak_groups` claim against this
# group name for regular (non-admin) Jellyfin access. Nested under akR-standard-user.
attributes: {}
+5 -5
View File
@@ -1,6 +1,6 @@
# Permission group akP-media-cheeztv (name = filename). Per-service entitlement
# for the kids ("cheeztv") media tier: NOT bound to any application (no
# `application` field), so it does not gate OIDC. It exists purely to appear in
# the user's hierarchical `ak_groups` claim, which the media proxy reads to
# decide whether to route/authorize the cheeztv backend.
attributes: {}
# for the kids ("cheeztv") media tier. Bound to the jellyfin application, so it
# gates Jellyfin SSO access: only members (via akR-media-adult or akR-media-kids)
# may authorize. It also appears in the user's hierarchical `ak_groups` claim,
# which the media proxy reads to route/authorize the cheeztv backend.
application: jellyfin
+5 -5
View File
@@ -1,6 +1,6 @@
# Permission group akP-media-fafflix (name = filename). Per-service entitlement
# for the adult ("fafflix") media tier: NOT bound to any application (no
# `application` field), so it does not gate OIDC. It exists purely to appear in
# the user's hierarchical `ak_groups` claim, which the media proxy reads to
# decide whether to route/authorize the fafflix backend.
attributes: {}
# for the adult ("fafflix") media tier. Bound to the jellyfin application, so it
# gates Jellyfin SSO access: only members (via akR-media-adult) may authorize.
# It also appears in the user's hierarchical `ak_groups` claim, which the media
# proxy reads to route/authorize the fafflix backend.
application: jellyfin
+27
View File
@@ -0,0 +1,27 @@
# LDAP provider + outpost for Jellyfin native-client app-password validation.
#
# Jellyfin's web UI signs in via OIDC (see providers_oauth2/jellyfin.yaml), but
# native clients (mobile/TV apps, DLNA) cannot do a browser OIDC dance. Those
# clients authenticate against this Authentik LDAP outpost using their username
# plus an Authentik "App password" token as the bind password. The Jellyfin LDAP
# plugin binds as the user; a successful bind == valid app-password.
#
# bind_mode "direct": the outpost runs the bind_flow (default-authentication-flow)
# on every bind request, so app-password revocation takes effect immediately.
# search_mode "direct": entries are read live from the Authentik API. Search is
# gated by Authentik's directory permissions -- the bound user is NOT granted the
# "Search full LDAP directory" permission, so it can only read its own entry,
# which is all the Jellyfin plugin needs to resolve the user after bind.
#
# base_dn is the Authentik default LDAP tree. The module creates the matching
# authentik_application (slug jellyfin-ldap) and authentik_outpost
# (name jellyfin-ldap-outpost, type ldap). The outpost's API token is generated
# by Authentik AFTER apply and must be seeded into KV for the k8s outpost
# Deployment to consume: kv/kubernetes/namespace/authentik/default/outpost-token (key: token).
name: Jellyfin LDAP
bind_flow: default-authentication-flow
unbind_flow: default-invalidation-flow
base_dn: DC=ldap,DC=goauthentik,DC=io
bind_mode: direct
search_mode: direct
mfa_support: true
+29 -13
View File
@@ -1,29 +1,45 @@
# OAuth2/OIDC provider + application for Jellyfin
# (jellyfin.k8s.syd1.au.unkin.net), consumed by jellyfin-plugin-sso (OIDC) so
# the web UI signs in via Authentik while native clients keep Jellyfin local/API
# auth. client_secret is read from Vault, not committed. The plugin requests the
# `ak_groups` scope and matches the hierarchical groups claim against
# akP-jellyfin-admin / akP-jellyfin-user for its admin/user role mapping, so no
# role_mappings claim is needed here. The path segment "authentik" is the SSO
# provider name configured in the plugin — it must match on the Jellyfin side.
# OAuth2/OIDC provider + application for the Jellyfin web UI SSO, consumed by
# jellyfin-plugin-sso (OIDC) so the browser signs in via Authentik while native
# clients use the LDAP outpost (see providers_ldap/jellyfin-ldap.yaml).
#
# ONE shared confidential client (client_id jellyfin) serves BOTH Jellyfin
# instances -- fafflix (adults, jellyfin.k8s.syd1.au.unkin.net) and cheeztv
# (kids, cheeztv.unkin.net + cheeztv.k8s.syd1.au.unkin.net). Each instance runs
# the SSO plugin with provider name "authentik", so its callback is
# https://<host>/sso/OID/redirect/authentik (verified plugin path shape); all
# three hosts are listed as strict redirect URIs.
#
# Access is gated to media users only: akP-media-fafflix and akP-media-cheeztv
# carry `application: jellyfin` and bind to this app, so only members of the
# media roles (akR-media-adult / akR-media-kids) can authorize. Per-instance
# authorization (adults -> fafflix + cheeztv, kids -> cheeztv only) is enforced
# downstream by the media proxy reading the hierarchical ak_groups claim.
# Admin vs. user role inside Jellyfin is still mapped by the plugin matching the
# ak_groups claim against akP-jellyfin-admin / akP-jellyfin-user.
#
# client_secret is read from Vault (seeded out of band), never committed. Both
# instances share this one secret; terraform reads it from the fafflix namespace
# path, and the cheeztv Deployment reads the same value from its own namespace.
name: Jellyfin
authorization_flow: default-provider-authorization-implicit-consent
invalidation_flow: default-provider-invalidation-flow
client_type: confidential
client_id: jellyfin
# Explicit dashboard tile launch URL (the Jellyfin web UI).
# Explicit dashboard tile launch URL (the fafflix Jellyfin web UI).
launch_url: https://jellyfin.k8s.syd1.au.unkin.net/
client_secret_vault:
mount: kv
path: kubernetes/namespace/jellyfin/default/oauth-credentials
path: kubernetes/namespace/fafflix/default/oauth-credentials
scope_mappings:
- goauthentik.io/providers/oauth2/scope-openid
- goauthentik.io/providers/oauth2/scope-email
- goauthentik.io/providers/oauth2/scope-profile
redirect_uris:
# jellyfin-plugin-sso OIDC callback (plugin >= 3.5.2).
# fafflix (adults)
- matching_mode: strict
url: https://jellyfin.k8s.syd1.au.unkin.net/sso/OID/redirect/authentik
# Legacy plugin callback path, kept so older plugin builds also work.
# cheeztv (kids) -- external + in-cluster hostnames
- matching_mode: strict
url: https://jellyfin.k8s.syd1.au.unkin.net/sso/OID/r/authentik
url: https://cheeztv.unkin.net/sso/OID/redirect/authentik
- matching_mode: strict
url: https://cheeztv.k8s.syd1.au.unkin.net/sso/OID/redirect/authentik
+14 -2
View File
@@ -169,12 +169,24 @@ resource "authentik_provider_oauth2" "this" {
grant_types = each.value.grant_types
}
# Resolve LDAP bind/unbind flows by slug (mirrors the oauth2/saml handling) so
# configs reference human-readable flow slugs instead of Authentik UUIDs.
data "authentik_flow" "ldap_bind" {
for_each = var.providers_ldap
slug = each.value.bind_flow
}
data "authentik_flow" "ldap_unbind" {
for_each = var.providers_ldap
slug = each.value.unbind_flow
}
resource "authentik_provider_ldap" "this" {
for_each = var.providers_ldap
name = each.value.name
bind_flow = each.value.bind_flow
unbind_flow = each.value.unbind_flow
bind_flow = data.authentik_flow.ldap_bind[each.key].id
unbind_flow = data.authentik_flow.ldap_unbind[each.key].id
base_dn = each.value.base_dn
certificate = each.value.certificate
tls_server_name = each.value.tls_server_name