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 } } } }