jellyfin: pull oidc plugin from streaming harbor and fix oidc redirect
This commit is contained in:
parent
dba8364c74
commit
5b0fbd344b
@ -29,6 +29,44 @@ spec:
|
|||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
runAsGroup: 65532
|
runAsGroup: 65532
|
||||||
initContainers:
|
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
|
- name: install-oidc-plugin
|
||||||
image: alpine:3.20
|
image: alpine:3.20
|
||||||
securityContext:
|
securityContext:
|
||||||
@ -36,8 +74,6 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: OIDC_PLUGIN_VERSION
|
- name: OIDC_PLUGIN_VERSION
|
||||||
value: "1.0.2.0"
|
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
|
- name: OIDC_ISSUER
|
||||||
value: "https://sso.bstein.dev/realms/atlas"
|
value: "https://sso.bstein.dev/realms/atlas"
|
||||||
- name: OIDC_REDIRECT_URI
|
- name: OIDC_REDIRECT_URI
|
||||||
@ -45,7 +81,7 @@ spec:
|
|||||||
- name: OIDC_LOGOUT_URI
|
- name: OIDC_LOGOUT_URI
|
||||||
value: "https://sso.bstein.dev/realms/atlas/protocol/openid-connect/logout?redirect_uri=https://stream.bstein.dev/"
|
value: "https://sso.bstein.dev/realms/atlas/protocol/openid-connect/logout?redirect_uri=https://stream.bstein.dev/"
|
||||||
- name: OIDC_SCOPES
|
- name: OIDC_SCOPES
|
||||||
value: "openid,profile,email,groups"
|
value: "openid,profile,email"
|
||||||
- name: OIDC_ROLE_CLAIM
|
- name: OIDC_ROLE_CLAIM
|
||||||
value: "groups"
|
value: "groups"
|
||||||
- name: OIDC_CLIENT_ID
|
- name: OIDC_CLIENT_ID
|
||||||
@ -61,6 +97,8 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
|
- name: oidc-plugin
|
||||||
|
mountPath: /plugin-src
|
||||||
command: ["/bin/sh", "-c"]
|
command: ["/bin/sh", "-c"]
|
||||||
args:
|
args:
|
||||||
- |
|
- |
|
||||||
@ -70,16 +108,20 @@ spec:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm -rf "/config/plugins/LDAP Authentication_20.0.0.0"
|
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}"
|
plugin_dir="/config/plugins/OIDC Authentication_${OIDC_PLUGIN_VERSION}"
|
||||||
config_dir="/config/plugins/configurations"
|
config_dir="/config/plugins/configurations"
|
||||||
tmp_zip="$(mktemp)"
|
plugin_zip="/plugin-src/OIDC_Authentication_${OIDC_PLUGIN_VERSION}-net9.zip"
|
||||||
echo "Downloading OIDC plugin ${OIDC_PLUGIN_VERSION} from ${OIDC_PLUGIN_URL}"
|
if [ ! -s "${plugin_zip}" ]; then
|
||||||
wget -O "${tmp_zip}" "${OIDC_PLUGIN_URL}"
|
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}"
|
rm -rf "${plugin_dir}"
|
||||||
mkdir -p "${plugin_dir}" "${config_dir}"
|
mkdir -p "${plugin_dir}" "${config_dir}"
|
||||||
unzip -o "${tmp_zip}" -d "${plugin_dir}"
|
unzip -o "${plugin_zip}" -d "${plugin_dir}"
|
||||||
rm -f "${tmp_zip}"
|
rm -f "${plugin_dir}"/Microsoft.Extensions.*.dll
|
||||||
cat >"${plugin_dir}/meta.json" <<'EOF'
|
cat >"${plugin_dir}/meta.json" <<'EOF'
|
||||||
{
|
{
|
||||||
"category": "Authentication",
|
"category": "Authentication",
|
||||||
@ -89,7 +131,7 @@ spec:
|
|||||||
"name": "OIDC Authentication",
|
"name": "OIDC Authentication",
|
||||||
"overview": "Enable Single Sign-On (SSO) for Jellyfin using an OpenID Connect provider.",
|
"overview": "Enable Single Sign-On (SSO) for Jellyfin using an OpenID Connect provider.",
|
||||||
"owner": "lolerskatez",
|
"owner": "lolerskatez",
|
||||||
"targetAbi": "10.11.3.0",
|
"targetAbi": "10.11.5.0",
|
||||||
"timestamp": "2025-12-17T04:00:00Z",
|
"timestamp": "2025-12-17T04:00:00Z",
|
||||||
"version": "1.0.2.0",
|
"version": "1.0.2.0",
|
||||||
"status": "Active",
|
"status": "Active",
|
||||||
@ -104,7 +146,8 @@ spec:
|
|||||||
[ -z "${trimmed}" ] && continue
|
[ -z "${trimmed}" ] && continue
|
||||||
scope_lines="${scope_lines} <string>${trimmed}</string>\n"
|
scope_lines="${scope_lines} <string>${trimmed}</string>\n"
|
||||||
done
|
done
|
||||||
cat >"${config_dir}/OIDC Authentication.xml" <<EOF
|
config_file="${config_dir}/JellyfinOIDCPlugin.v2.xml"
|
||||||
|
cat >"${config_file}" <<EOF
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
<OidEndpoint>${OIDC_ISSUER}</OidEndpoint>
|
<OidEndpoint>${OIDC_ISSUER}</OidEndpoint>
|
||||||
@ -120,7 +163,7 @@ spec:
|
|||||||
<AllowRememberMe>false</AllowRememberMe>
|
<AllowRememberMe>false</AllowRememberMe>
|
||||||
</PluginConfiguration>
|
</PluginConfiguration>
|
||||||
EOF
|
EOF
|
||||||
chown -R 1000:65532 "${plugin_dir}" "${config_dir}/OIDC Authentication.xml"
|
chown -R 1000:65532 "${plugin_dir}" "${config_file}"
|
||||||
runtimeClassName: nvidia
|
runtimeClassName: nvidia
|
||||||
containers:
|
containers:
|
||||||
- name: jellyfin
|
- name: jellyfin
|
||||||
@ -140,6 +183,22 @@ spec:
|
|||||||
value: "65532"
|
value: "65532"
|
||||||
- name: UMASK
|
- name: UMASK
|
||||||
value: "002"
|
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 " <script src=\"/api/oidc/inject\"></script>"; inserted=1} {print}' "${target}" > "${tmp}"
|
||||||
|
cp "${tmp}" "${target}"
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
nvidia.com/gpu: 1
|
nvidia.com/gpu: 1
|
||||||
@ -157,6 +216,8 @@ spec:
|
|||||||
- name: media
|
- name: media
|
||||||
mountPath: /media
|
mountPath: /media
|
||||||
securityContext:
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
runAsGroup: 0
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: false
|
readOnlyRootFilesystem: false
|
||||||
volumes:
|
volumes:
|
||||||
@ -169,3 +230,5 @@ spec:
|
|||||||
- name: media
|
- name: media
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: jellyfin-media-asteria-new
|
claimName: jellyfin-media-asteria-new
|
||||||
|
- name: oidc-plugin
|
||||||
|
emptyDir: {}
|
||||||
|
|||||||
568
services/jellyfin/oidc/Jenkinsfile
vendored
Normal file
568
services/jellyfin/oidc/Jenkinsfile
vendored
Normal file
@ -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<IUserManager>();
|
||||||
|
private static readonly Dictionary<string, object> StateManager = new(); // Store AuthorizeState objects
|
||||||
|
|
||||||
|
[HttpGet("start")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<string>()
|
||||||
|
: 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<IActionResult> 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<string>()
|
||||||
|
: 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'
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<AssemblyName>JellyfinOIDCPlugin.v2</AssemblyName>
|
||||||
|
<RootNamespace>JellyfinOIDCPlugin</RootNamespace>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyVersion>1.0.2.0</AssemblyVersion>
|
||||||
|
<FileVersion>1.0.2.0</FileVersion>
|
||||||
|
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Data" Version="10.11.5">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Jellyfin.Database.Implementations" Version="10.11.5">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="IdentityModel.OidcClient" Version="5.2.1">
|
||||||
|
<PrivateAssets>none</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11">
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="web\\*.html" />
|
||||||
|
<EmbeddedResource Include="web\\*.js" />
|
||||||
|
<EmbeddedResource Include="web\\*.css" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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') {
|
pipelineJob('ci-demo') {
|
||||||
triggers {
|
triggers {
|
||||||
scm('H/1 * * * *')
|
scm('H/1 * * * *')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user