diff --git a/services/jellyfin/oidc/Jenkinsfile b/services/jellyfin/oidc/Jenkinsfile deleted file mode 100644 index b2b0b158..00000000 --- a/services/jellyfin/oidc/Jenkinsfile +++ /dev/null @@ -1,683 +0,0 @@ -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 -""" - } - } - 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" - SUITE_NAME = "jellyfin-oidc-plugin" - PUSHGATEWAY_URL = "http://platform-quality-gateway.monitoring.svc.cluster.local:9091" - } - stages { - stage('Checkout') { - steps { - container('dotnet') { - checkout scm - } - } - } - stage('Build plugin') { - steps { - container('dotnet') { - sh ''' - set -eu - 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('Quality gate smoke tests') { - steps { - container('dotnet') { - sh ''' - set -eu - apt-get update - apt-get install -y --no-install-recommends unzip - WORKDIR="$(pwd)/build" - ARTIFACT="${WORKDIR}/artifact/OIDC_Authentication_${PLUGIN_VERSION}-net9.zip" - test -s "${ARTIFACT}" - unzip -l "${ARTIFACT}" > "${WORKDIR}/artifact-list.txt" - grep -q 'JellyfinOIDCPlugin.v2.dll' "${WORKDIR}/artifact-list.txt" - cat > "${WORKDIR}/quality-summary.env" <<'EOF' -tests=1 -passed=1 -failed=0 -errors=0 -skipped=0 -EOF - ''' - } - } - } - stage('Push to Harbor') { - steps { - container('dotnet') { - withCredentials([usernamePassword(credentialsId: 'harbor-robot', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')]) { - sh ''' - set -eu - 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 --disable-path-validation - "${ORAS_BIN}" push "${HARBOR_REPO}:latest" "${artifact}:application/zip" --artifact-type application/zip --disable-path-validation - ''' - } - } - } - } - } - post { - always { - script { - env.QUALITY_STATUS = currentBuild.currentResult == 'SUCCESS' ? 'ok' : 'failed' - } - container('dotnet') { - sh ''' - set -eu - apt-get update >/dev/null 2>&1 - apt-get install -y --no-install-recommends python3 >/dev/null 2>&1 - python3 - <<'PY' -import os -import urllib.request -from pathlib import Path - -suite = os.getenv("SUITE_NAME", "jellyfin-oidc-plugin") -status = os.getenv("QUALITY_STATUS", "failed") -gateway = os.getenv("PUSHGATEWAY_URL", "http://platform-quality-gateway.monitoring.svc.cluster.local:9091").rstrip("/") -summary_path = Path("build/quality-summary.env") - -totals = {"tests": 1, "passed": 0, "failed": 1, "errors": 0, "skipped": 0} -if summary_path.exists(): - for raw in summary_path.read_text(encoding="utf-8").splitlines(): - if "=" not in raw: - continue - key, value = raw.split("=", 1) - key = key.strip() - if key in totals: - try: - totals[key] = int(float(value.strip())) - except ValueError: - pass -if status != "ok": - totals["failed"] = max(totals["failed"], 1) - totals["passed"] = 0 - -def read_metrics() -> str: - try: - with urllib.request.urlopen(f"{gateway}/metrics", timeout=10) as resp: - return resp.read().decode("utf-8", errors="replace") - except Exception: - return "" - -def read_counter(text: str, counter_status: str) -> float: - for line in text.splitlines(): - if not line.startswith("platform_quality_gate_runs_total{"): - continue - if 'job="platform-quality-ci"' not in line: - continue - if f'suite="{suite}"' not in line: - continue - if f'status="{counter_status}"' not in line: - continue - parts = line.split() - if len(parts) < 2: - continue - try: - return float(parts[1]) - except ValueError: - return 0.0 - return 0.0 - -metrics = read_metrics() -ok_count = read_counter(metrics, "ok") -failed_count = read_counter(metrics, "failed") -if status == "ok": - ok_count += 1 -else: - failed_count += 1 - -payload = "\n".join( - [ - "# TYPE platform_quality_gate_runs_total counter", - f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}', - f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}', - "# TYPE jellyfin_oidc_quality_gate_tests_total gauge", - f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="passed"}} {totals["passed"]}', - f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failed"]}', - f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="error"}} {totals["errors"]}', - f'jellyfin_oidc_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', - ] -) + "\n" - -req = urllib.request.Request( - f"{gateway}/metrics/job/platform-quality-ci/suite/{suite}", - data=payload.encode("utf-8"), - method="POST", - headers={"Content-Type": "text/plain"}, -) -with urllib.request.urlopen(req, timeout=10) as resp: - if resp.status >= 400: - raise RuntimeError(f"push failed: {resp.status}") -PY - ''' - } - container('dotnet') { - archiveArtifacts artifacts: 'build/artifact/*.zip,build/artifact-list.txt,build/quality-summary.env', allowEmptyArchive: true - } - } - } -}