diff --git a/services/jellyfin/deployment.yaml b/services/jellyfin/deployment.yaml index afd5a74..86e313a 100644 --- a/services/jellyfin/deployment.yaml +++ b/services/jellyfin/deployment.yaml @@ -29,6 +29,44 @@ spec: fsGroupChangePolicy: OnRootMismatch runAsGroup: 65532 initContainers: + - name: fetch-oidc-plugin + image: alpine:3.20 + securityContext: + runAsUser: 0 + env: + - name: OIDC_PLUGIN_REPO + value: "registry.bstein.dev/streaming/oidc-plugin" + - name: OIDC_PLUGIN_TAG + value: "10.11.5" + - name: ORAS_USERNAME + valueFrom: + secretKeyRef: + name: harbor-robot + key: username + optional: true + - name: ORAS_PASSWORD + valueFrom: + secretKeyRef: + name: harbor-robot + key: password + optional: true + volumeMounts: + - name: oidc-plugin + mountPath: /plugin-src + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + apk add --no-cache curl tar + ORAS_VERSION=1.2.0 + curl -sSL "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin oras + ref="${OIDC_PLUGIN_REPO}:${OIDC_PLUGIN_TAG}" + cd /plugin-src + if [ -n "${ORAS_USERNAME:-}" ] && [ -n "${ORAS_PASSWORD:-}" ]; then + oras login "$(echo "${OIDC_PLUGIN_REPO}" | cut -d/ -f1)" -u "${ORAS_USERNAME}" -p "${ORAS_PASSWORD}" + fi + oras pull --allow-path-traversal "${ref}" + ls -lh /plugin-src - name: install-oidc-plugin image: alpine:3.20 securityContext: @@ -36,8 +74,6 @@ spec: env: - name: OIDC_PLUGIN_VERSION value: "1.0.2.0" - - name: OIDC_PLUGIN_URL - value: "https://raw.githubusercontent.com/lolerskatez/JellyfinOIDCPlugin/master/OIDC_Authentication_1.0.2.0.zip" - name: OIDC_ISSUER value: "https://sso.bstein.dev/realms/atlas" - name: OIDC_REDIRECT_URI @@ -45,7 +81,7 @@ spec: - name: OIDC_LOGOUT_URI value: "https://sso.bstein.dev/realms/atlas/protocol/openid-connect/logout?redirect_uri=https://stream.bstein.dev/" - name: OIDC_SCOPES - value: "openid,profile,email,groups" + value: "openid,profile,email" - name: OIDC_ROLE_CLAIM value: "groups" - name: OIDC_CLIENT_ID @@ -61,6 +97,8 @@ spec: volumeMounts: - name: config mountPath: /config + - name: oidc-plugin + mountPath: /plugin-src command: ["/bin/sh", "-c"] args: - | @@ -70,16 +108,20 @@ spec: exit 1 fi rm -rf "/config/plugins/LDAP Authentication_20.0.0.0" - apk add --no-cache wget unzip + apk add --no-cache unzip plugin_dir="/config/plugins/OIDC Authentication_${OIDC_PLUGIN_VERSION}" config_dir="/config/plugins/configurations" - tmp_zip="$(mktemp)" - echo "Downloading OIDC plugin ${OIDC_PLUGIN_VERSION} from ${OIDC_PLUGIN_URL}" - wget -O "${tmp_zip}" "${OIDC_PLUGIN_URL}" + plugin_zip="/plugin-src/OIDC_Authentication_${OIDC_PLUGIN_VERSION}-net9.zip" + if [ ! -s "${plugin_zip}" ]; then + echo "Plugin zip missing at ${plugin_zip}" >&2 + echo "Contents of /plugin-src:" >&2 + ls -lah /plugin-src >&2 || true + exit 1 + fi rm -rf "${plugin_dir}" mkdir -p "${plugin_dir}" "${config_dir}" - unzip -o "${tmp_zip}" -d "${plugin_dir}" - rm -f "${tmp_zip}" + unzip -o "${plugin_zip}" -d "${plugin_dir}" + rm -f "${plugin_dir}"/Microsoft.Extensions.*.dll cat >"${plugin_dir}/meta.json" <<'EOF' { "category": "Authentication", @@ -89,7 +131,7 @@ spec: "name": "OIDC Authentication", "overview": "Enable Single Sign-On (SSO) for Jellyfin using an OpenID Connect provider.", "owner": "lolerskatez", - "targetAbi": "10.11.3.0", + "targetAbi": "10.11.5.0", "timestamp": "2025-12-17T04:00:00Z", "version": "1.0.2.0", "status": "Active", @@ -104,7 +146,8 @@ spec: [ -z "${trimmed}" ] && continue scope_lines="${scope_lines} ${trimmed}\n" done - cat >"${config_dir}/OIDC Authentication.xml" <"${config_file}" < ${OIDC_ISSUER} @@ -120,7 +163,7 @@ spec: false EOF - chown -R 1000:65532 "${plugin_dir}" "${config_dir}/OIDC Authentication.xml" + chown -R 1000:65532 "${plugin_dir}" "${config_file}" runtimeClassName: nvidia containers: - name: jellyfin @@ -140,6 +183,22 @@ spec: value: "65532" - name: UMASK value: "002" + lifecycle: + postStart: + exec: + command: + - /bin/sh + - -c + - | + set -e + target="/jellyfin/jellyfin-web/index.html" + marker='api/oidc/inject' + if grep -q "${marker}" "${target}"; then + exit 0 + fi + tmp="$(mktemp)" + awk -v marker="${marker}" 'BEGIN{inserted=0} /<\/head>/ && !inserted {print " "; inserted=1} {print}' "${target}" > "${tmp}" + cp "${tmp}" "${target}" resources: limits: nvidia.com/gpu: 1 @@ -157,6 +216,8 @@ spec: - name: media mountPath: /media securityContext: + runAsUser: 0 + runAsGroup: 0 allowPrivilegeEscalation: false readOnlyRootFilesystem: false volumes: @@ -169,3 +230,5 @@ spec: - name: media persistentVolumeClaim: claimName: jellyfin-media-asteria-new + - name: oidc-plugin + emptyDir: {} diff --git a/services/jellyfin/oidc/Jenkinsfile b/services/jellyfin/oidc/Jenkinsfile new file mode 100644 index 0000000..6886dc9 --- /dev/null +++ b/services/jellyfin/oidc/Jenkinsfile @@ -0,0 +1,568 @@ +pipeline { + agent { + kubernetes { + yaml """ +apiVersion: v1 +kind: Pod +spec: + restartPolicy: Never + containers: + - name: dotnet + image: mcr.microsoft.com/dotnet/sdk:9.0 + command: + - cat + tty: true +""" + } + } + options { + timestamps() + } + parameters { + string(name: 'HARBOR_REPO', defaultValue: 'registry.bstein.dev/streaming/oidc-plugin', description: 'OCI repository for the plugin artifact') + string(name: 'JELLYFIN_VERSION', defaultValue: '10.11.5', description: 'Jellyfin version to tag the plugin with') + string(name: 'PLUGIN_VERSION', defaultValue: '1.0.2.0', description: 'Plugin version') + } + environment { + ORAS_VERSION = "1.2.0" + DOTNET_CLI_TELEMETRY_OPTOUT = "1" + DOTNET_SKIP_FIRST_TIME_EXPERIENCE = "1" + } + stages { + stage('Checkout') { + steps { + container('dotnet') { + checkout scm + } + } + } + stage('Build plugin') { + steps { + container('dotnet') { + sh ''' + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends zip curl ca-certificates git + WORKDIR="$(pwd)/build" + SRC_DIR="${WORKDIR}/src" + DIST_DIR="${WORKDIR}/dist" + ART_DIR="${WORKDIR}/artifact" + rm -rf "${SRC_DIR}" "${DIST_DIR}" "${ART_DIR}" + mkdir -p "${SRC_DIR}" "${DIST_DIR}" "${ART_DIR}" + git clone https://github.com/lolerskatez/JellyfinOIDCPlugin.git "${SRC_DIR}" + cd "${SRC_DIR}" + # Override controllers to avoid DI version issues and add injection script + cat > Controllers/OidcController.cs <<'EOF' +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using IdentityModel.OidcClient; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace JellyfinOIDCPlugin.Controllers; + +#nullable enable + +[ApiController] +[Route("api/oidc")] +public class OidcController : ControllerBase +{ + private IUserManager UserManager => HttpContext.RequestServices.GetRequiredService(); + private static readonly Dictionary StateManager = new(); // Store AuthorizeState objects + + [HttpGet("start")] + public async Task Start() + { + var config = Plugin.Instance?.Configuration; + if (config == null) + { + return BadRequest("Plugin not initialized"); + } + + var options = new OidcClientOptions + { + Authority = config.OidEndpoint?.Trim(), + ClientId = config.OidClientId?.Trim(), + ClientSecret = config.OidSecret?.Trim(), + RedirectUri = GetRedirectUri(), + Scope = string.Join(" ", config.OidScopes) + }; + + try + { + var client = new OidcClient(options); + var result = await client.PrepareLoginAsync().ConfigureAwait(false); + + // Store the authorize state for the callback + var stateString = (string)result.GetType().GetProperty("State")?.GetValue(result); + if (!string.IsNullOrEmpty(stateString)) + { + StateManager[stateString] = result; + } + + var startUrl = (string)result.GetType().GetProperty("StartUrl")?.GetValue(result); + if (string.IsNullOrEmpty(startUrl)) + { + Console.WriteLine("OIDC: Could not get StartUrl from OIDC result"); + return BadRequest("OIDC initialization failed"); + } + + return Redirect(startUrl); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC start error: {ex}"); + return BadRequest("OIDC error: " + ex.Message); + } + } + + [HttpGet("callback")] + public async Task Callback() + { + var config = Plugin.Instance?.Configuration; + if (config == null) + { + return BadRequest("Plugin not initialized"); + } + + try + { + var stateParam = Request.Query["state"].ToString(); + if (string.IsNullOrEmpty(stateParam) || !StateManager.TryGetValue(stateParam, out var storedState)) + { + Console.WriteLine($"OIDC: Invalid state {stateParam}"); + return BadRequest("Invalid state"); + } + + var options = new OidcClientOptions + { + Authority = config.OidEndpoint?.Trim(), + ClientId = config.OidClientId?.Trim(), + ClientSecret = config.OidSecret?.Trim(), + RedirectUri = GetRedirectUri(), + Scope = string.Join(" ", config.OidScopes) + }; + + var client = new OidcClient(options); + // Cast stored state to AuthorizeState - it's stored as object + var authorizeState = (AuthorizeState)storedState; + var result = await client.ProcessResponseAsync(Request.QueryString.Value, authorizeState).ConfigureAwait(false); + + if (result.IsError) + { + Console.WriteLine($"OIDC callback failed: {result.Error} - {result.ErrorDescription}"); + return BadRequest("OIDC authentication failed"); + } + + // Get email from claims + var email = result.User?.FindFirst("email")?.Value ?? + result.User?.FindFirst("preferred_username")?.Value ?? + result.User?.FindFirst("sub")?.Value; + + if (string.IsNullOrEmpty(email)) + { + Console.WriteLine("OIDC: No email/username found in OIDC response"); + return BadRequest("No email/username found in OIDC response"); + } + + // Get or create user + var user = UserManager.GetUserByName(email); + if (user == null) + { + Console.WriteLine($"OIDC: Creating new user {email}"); + user = await UserManager.CreateUserAsync(email).ConfigureAwait(false); + } + + // Set authentication provider + user.AuthenticationProviderId = "OIDC"; + + // Get roles from claims + var rolesClaimValue = result.User?.FindFirst(config.RoleClaim)?.Value; + var roles = string.IsNullOrEmpty(rolesClaimValue) + ? Array.Empty() + : rolesClaimValue.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); + + // Set permissions based on groups + var isAdmin = roles.Any(r => r.Equals("admin", StringComparison.OrdinalIgnoreCase)); + var isPowerUser = roles.Any(r => r.Equals("Power User", StringComparison.OrdinalIgnoreCase)) && !isAdmin; + + Console.WriteLine($"OIDC: User {email} authenticated. Admin: {isAdmin}, PowerUser: {isPowerUser}"); + + // Update user in database + await UserManager.UpdateUserAsync(user).ConfigureAwait(false); + + StateManager.Remove(stateParam); + + // Redirect to Jellyfin main page + return Redirect("/"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC callback error: {ex}"); + return BadRequest("OIDC error: " + ex.Message); + } + } + + [HttpPost("token")] + public async Task ExchangeToken([FromBody] TokenExchangeRequest request) + { + var config = Plugin.Instance?.Configuration; + if (config == null) + { + Console.WriteLine("OIDC: Plugin not initialized"); + return BadRequest("Plugin not initialized"); + } + + if (string.IsNullOrEmpty(request?.AccessToken)) + { + Console.WriteLine("OIDC: No access token provided"); + return BadRequest("Access token is required"); + } + + try + { + Console.WriteLine("OIDC: Processing token exchange request"); + + // Validate the token with the OIDC provider using UserInfo endpoint + var options = new OidcClientOptions + { + Authority = config.OidEndpoint?.Trim(), + ClientId = config.OidClientId?.Trim(), + ClientSecret = config.OidSecret?.Trim(), + Scope = string.Join(" ", config.OidScopes) + }; + + var client = new OidcClient(options); + + // Use the access token to get user info + var userInfoResult = await client.GetUserInfoAsync(request.AccessToken).ConfigureAwait(false); + + if (userInfoResult.IsError) + { + Console.WriteLine($"OIDC: Failed to get user info: {userInfoResult.Error}"); + return Unauthorized("Invalid access token"); + } + + // Extract email/username from user info + var email = userInfoResult.Claims.FirstOrDefault(c => c.Type == "email")?.Value ?? + userInfoResult.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value ?? + userInfoResult.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; + + if (string.IsNullOrEmpty(email)) + { + Console.WriteLine("OIDC: No email/username found in token"); + return BadRequest("No email/username found in token"); + } + + // Get or create user + var user = UserManager.GetUserByName(email); + if (user == null) + { + if (!config.AutoCreateUser) + { + Console.WriteLine($"OIDC: User {email} not found and auto-create disabled"); + return Unauthorized("User does not exist and auto-creation is disabled"); + } + + Console.WriteLine($"OIDC: Creating new user from token {email}"); + user = await UserManager.CreateUserAsync(email).ConfigureAwait(false); + } + + // Update user authentication provider + user.AuthenticationProviderId = "OIDC"; + + // Get roles from claims + var rolesClaimName = config.RoleClaim ?? "groups"; + var rolesClaimValue = userInfoResult.Claims.FirstOrDefault(c => c.Type == rolesClaimName)?.Value; + var roles = string.IsNullOrEmpty(rolesClaimValue) + ? Array.Empty() + : rolesClaimValue.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries); + + // Set permissions based on groups + var isAdmin = roles.Any(r => r.Equals("admin", StringComparison.OrdinalIgnoreCase)); + var isPowerUser = roles.Any(r => r.Equals("Power User", StringComparison.OrdinalIgnoreCase)) && !isAdmin; + + Console.WriteLine($"OIDC: Token exchange for {email} Admin:{isAdmin} Power:{isPowerUser}"); + + // Update user in database + await UserManager.UpdateUserAsync(user).ConfigureAwait(false); + + // Return success with user info + return Ok(new TokenExchangeResponse + { + Success = true, + UserId = user.Id.ToString(), + Username = user.Username, + Email = email, + IsAdmin = isAdmin, + Message = "User authenticated successfully" + }); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC token exchange error: {ex}"); + return StatusCode(500, $"Token exchange failed: {ex.Message}"); + } + } + + private string GetRedirectUri() + { + var configured = Plugin.Instance?.Configuration?.RedirectUri; + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured!; + } + + return $"{Request.Scheme}://{Request.Host}/api/oidc/callback"; + } +} + +public class TokenExchangeRequest +{ + public string? AccessToken { get; set; } + public string? IdToken { get; set; } +} + +public class TokenExchangeResponse +{ + public bool Success { get; set; } + public string? UserId { get; set; } + public string? Username { get; set; } + public string? Email { get; set; } + public bool IsAdmin { get; set; } + public string? Message { get; set; } +} +EOF + + cat > Controllers/OidcStaticController.cs <<'EOF' +using System; +using System.IO; +using System.Reflection; +using MediaBrowser.Common.Plugins; +using Microsoft.AspNetCore.Mvc; + +namespace JellyfinOIDCPlugin.Controllers; + +[ApiController] +[Route("api/oidc")] +public class OidcStaticController : ControllerBase +{ + [HttpGet("login.js")] + public IActionResult GetLoginScript() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("JellyfinOIDCPlugin.web.oidc-login.js"); + if (stream == null) + { + Console.WriteLine("OIDC: Login script resource not found"); + return NotFound(); + } + + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + return Content(content, "application/javascript"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC: Error serving login script {ex}"); + return StatusCode(500, "Error loading login script"); + } + } + + [HttpGet("loader.js")] + public IActionResult GetLoader() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("JellyfinOIDCPlugin.web.oidc-loader.js"); + if (stream == null) + { + Console.WriteLine("OIDC: Loader script resource not found"); + return NotFound(); + } + + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + return Content(content, "application/javascript"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC: Error serving loader script {ex}"); + return StatusCode(500, "Error loading loader script"); + } + } + + [HttpGet("inject")] + public IActionResult GetInject() + { + try + { + var script = @" +(function() { + console.log('[OIDC Plugin] Bootstrap inject started'); + + // Load oidc-loader.js dynamically + const loaderScript = document.createElement('script'); + loaderScript.src = '/api/oidc/loader.js'; + loaderScript.type = 'application/javascript'; + loaderScript.onerror = function() { + console.error('[OIDC Plugin] Failed to load loader.js'); + }; + loaderScript.onload = function() { + console.log('[OIDC Plugin] Loader.js loaded successfully'); + }; + + // Append to head or body + const target = document.head || document.documentElement; + target.appendChild(loaderScript); + + console.log('[OIDC Plugin] Bootstrap script appended to page'); +})(); +"; + return Content(script, "application/javascript"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC: Error serving inject script {ex}"); + return StatusCode(500, "Error loading inject script"); + } + } + + [HttpGet("global.js")] + public IActionResult GetGlobalInjector() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("JellyfinOIDCPlugin.web.oidc-global-injector.js"); + if (stream == null) + { + Console.WriteLine("OIDC: Global injector resource not found"); + return NotFound(); + } + + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + return Content(content, "application/javascript"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC: Error serving global injector {ex}"); + return StatusCode(500, "Error loading global injector"); + } + } + + [HttpGet("config")] + public IActionResult GetConfigurationPage() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + using var stream = assembly.GetManifestResourceStream("JellyfinOIDCPlugin.web.configurationpage.html"); + if (stream == null) + { + Console.WriteLine("OIDC: Configuration page resource not found"); + return NotFound("Configuration page resource not found"); + } + + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + return Content(content, "text/html"); + } + catch (Exception ex) + { + Console.WriteLine($"OIDC: Error serving configuration page {ex}"); + return StatusCode(500, $"Error loading configuration page: {ex.Message}"); + } + } +} +EOF + cat > JellyfinOIDCPlugin.csproj <<'EOF' + + + net9.0 + JellyfinOIDCPlugin.v2 + JellyfinOIDCPlugin + latest + enable + enable + 1.0.2.0 + 1.0.2.0 + false + + + + runtime + + + runtime + + + runtime + + + runtime + + + runtime + + + none + + + runtime + + + + + + + + +EOF + dotnet restore + dotnet publish -c Release --no-self-contained -o "${DIST_DIR}" + cd "${DIST_DIR}" + zip -r "${ART_DIR}/OIDC_Authentication_${PLUGIN_VERSION}-net9.zip" . + ''' + } + } + } + stage('Push to Harbor') { + steps { + container('dotnet') { + withCredentials([usernamePassword(credentialsId: 'harbor-robot', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')]) { + sh ''' + set -euo pipefail + WORKDIR="$(pwd)/build" + ORAS_BIN="/usr/local/bin/oras" + curl -sSL "https://github.com/oras-project/oras/releases/download/v${ORAS_VERSION}/oras_${ORAS_VERSION}_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin oras + ref_host="$(echo "${HARBOR_REPO}" | cut -d/ -f1)" + "${ORAS_BIN}" login "${ref_host}" -u "${HARBOR_USERNAME}" -p "${HARBOR_PASSWORD}" + artifact="${WORKDIR}/artifact/OIDC_Authentication_${PLUGIN_VERSION}-net9.zip" + "${ORAS_BIN}" push "${HARBOR_REPO}:${JELLYFIN_VERSION}" "${artifact}:application/zip" --artifact-type application/zip + "${ORAS_BIN}" push "${HARBOR_REPO}:latest" "${artifact}:application/zip" --artifact-type application/zip + ''' + } + } + } + } + } + post { + always { + container('dotnet') { + archiveArtifacts artifacts: 'build/artifact/*.zip', allowEmptyArchive: true + } + } + } +} diff --git a/services/jenkins/configmap-jcasc.yaml b/services/jenkins/configmap-jcasc.yaml index 958e8a8..615412e 100644 --- a/services/jenkins/configmap-jcasc.yaml +++ b/services/jenkins/configmap-jcasc.yaml @@ -66,6 +66,22 @@ data: } } } + pipelineJob('jellyfin-oidc-plugin') { + definition { + cpsScm { + scm { + git { + remote { + url('https://scm.bstein.dev/bstein/titan-iac.git') + credentials('gitea-pat') + } + branches('*/main') + } + } + scriptPath('services/jellyfin/oidc/Jenkinsfile') + } + } + } pipelineJob('ci-demo') { triggers { scm('H/1 * * * *')