Files
computer-playbook/roles/web-app-jellyfin/files/shell/configure-auth.sh
kevinveenbirkenbachandClaude Opus 5 a8469e0642 fix(jellyfin): authenticate once, so the token survives to the password change
configure-auth.sh pins DeviceId="infinito-deploy" in CLIENT_HDR, and realign_admin_password authenticated twice with it: once for the access token, once for the user id. Jellyfin replaces the session bound to a device id when a new one is created, so the first token was already revoked when POST /Users/{id}/Password presented it. Run 33828309745 job 100890535194 shows the result as fifteen times "curl: (22) The requested URL returned error: 401", with the deploy otherwise complete (ok=2830 failed=1). get_user_id arrived with 1bf28bb2bf and carried the second call in from the start.

One AuthenticateByName response now yields both fields, read through token_of and user_id_of so neither expression is spelled twice.

Redeeming the reset pin clears the stored password: in that job forty authentications with the deployed password were denied, and the next one, with the pin string as the password, succeeded. Every exit after the redemption therefore states that the administrator is currently without one, and the two silent returns around ForgotPassword now log why they gave up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:54:31 +02:00

233 lines
10 KiB
Bash

#!/bin/bash
set -euo pipefail
API="${JELLYFIN_API}"
CURL="curl --connect-timeout 5 --max-time 30"
CT="${JELLYFIN_CT}"
PHASE="${JELLYFIN_PHASE}"
CLIENT_HDR='X-Emby-Authorization: MediaBrowser Client="infinito", Device="ansible", DeviceId="infinito-deploy", Version="1.0.0"'
log() { echo "[jellyfin-auth] $*"; }
wait_up() {
for _ in $(seq 1 60); do
${CURL} -fsS -o /dev/null "${API}/System/Info/Public" && return 0
sleep 5
done
log "Jellyfin did not become ready at ${API}"; return 1
}
complete_wizard() {
${CURL} -fsS -X POST "${API}/Startup/Configuration" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d '{"UICulture":"en-US","MetadataCountryCode":"US","PreferredMetadataLanguage":"en"}' >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
${CURL} -fsS "${API}/Startup/User" -H "${CLIENT_HDR}" >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
${CURL} -fsS -X POST "${API}/Startup/User" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d "{\"Name\":\"${JELLYFIN_ADMIN_USERNAME}\",\"Password\":\"${JELLYFIN_ADMIN_PASSWORD}\"}" >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
${CURL} -fsS -X POST "${API}/Startup/RemoteAccess" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d '{"EnableRemoteAccess":true,"EnableAutomaticPortMapping":false}' >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
${CURL} -fsS -X POST "${API}/Startup/Complete" -H "${CLIENT_HDR}" >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
}
authenticate() {
${CURL} -fsS -X POST "${API}/Users/AuthenticateByName" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d "{\"Username\":\"${JELLYFIN_ADMIN_USERNAME}\",\"Pw\":\"${1-${JELLYFIN_ADMIN_PASSWORD}}\"}"
}
token_of() { sed -n 's/.*"AccessToken":"\([^"]*\)".*/\1/p'; }
user_id_of() { sed -n 's/.*"User":{[^}]*"Id":"\([^"]*\)".*/\1/p'; }
get_token() {
authenticate | token_of
}
realign_admin_password() {
local pin pin_token user_id auth
${CURL} -fsS -X POST "${API}/Users/ForgotPassword" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d "{\"EnteredUsername\":\"${JELLYFIN_ADMIN_USERNAME}\"}" >/dev/null || {
log "ERROR: Jellyfin refused the password reset request"
return 1
}
pin="$(container exec "${CT}" sh -c 'cat /config/passwordreset*.json 2>/dev/null' \
| sed -n 's/.*"Pin":"\([^"]*\)".*/\1/p' | head -n1)" || pin=""
if [ -z "${pin}" ]; then
log "ERROR: Jellyfin accepted the reset request but wrote no pin file"
return 1
fi
${CURL} -fsS -X POST "${API}/Users/ForgotPassword/Pin" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
-d "{\"Pin\":\"${pin}\"}" >/dev/null || {
log "ERROR: Jellyfin rejected the reset pin"
return 1
}
auth="$(authenticate "${pin}")" || auth=""
pin_token="$(printf '%s' "${auth}" | token_of)"
user_id="$(printf '%s' "${auth}" | user_id_of)"
if [ -z "${pin_token}" ] || [ -z "${user_id}" ]; then
log "ERROR: redeeming the reset pin cleared the stored password and the administrator still does not authenticate; the account is now WITHOUT a password"
return 1
fi
${CURL} -fsS -X POST "${API}/Users/${user_id}/Password" -H "Content-Type: application/json" \
-H "Authorization: MediaBrowser Token=\"${pin_token}\"" \
-d "{\"CurrentPw\":\"${pin}\",\"NewPw\":\"${JELLYFIN_ADMIN_PASSWORD}\"}" >/dev/null || {
log "ERROR: the password change was rejected; the reset already cleared the old one, so the administrator is now WITHOUT a password"
return 1
}
log "realigned the administrator password with the one this deployment declares"
}
seed_admin_and_get_token() {
for _ in $(seq 1 40); do
complete_wizard
TOKEN="$(get_token || true)"
[ -n "${TOKEN:-}" ] && return 0
sleep 3
done
realign_admin_password || return 1
TOKEN="$(get_token)" || TOKEN=""
[ -n "${TOKEN:-}" ] && return 0
return 1
}
install_plugin() {
if ${CURL} -fsS -X POST "${API}/Packages/Installed/${1}" -H "Authorization: MediaBrowser Token=\"${TOKEN}\"" >/dev/null 2>&1; then
log "requested install: ${1}"
else
log "install request failed (already present?): ${1}"
fi
}
install_ldap_plugin() {
: "${JELLYFIN_LDAP_PLUGIN_URL:?missing (set via meta/addons/ldap-authentication.yml)}"
: "${JELLYFIN_LDAP_PLUGIN_VERSION:?missing (set via meta/addons/ldap-authentication.yml)}"
local tmp
tmp="$(mktemp -d)"
if ! ${CURL} --max-time 300 -fsSL -o "${tmp}/ldap.zip" "${JELLYFIN_LDAP_PLUGIN_URL}"; then
log "ERROR: failed to download LDAP plugin from ${JELLYFIN_LDAP_PLUGIN_URL}"
rm -rf "${tmp}"; return 1
fi
if command -v md5sum >/dev/null 2>&1 \
&& ! echo "${JELLYFIN_LDAP_PLUGIN_MD5} ${tmp}/ldap.zip" | md5sum -c - >/dev/null 2>&1; then
log "ERROR: LDAP plugin checksum mismatch"; rm -rf "${tmp}"; return 1
fi
python3 -m zipfile -e "${tmp}/ldap.zip" "${tmp}/ldap"
container exec "${CT}" mkdir -p "/config/plugins/LDAP Authentication_${JELLYFIN_LDAP_PLUGIN_VERSION}"
# nocheck: container-cp - plugin payload is staged in a script-local tmp dir on the same node
container cp "${tmp}/ldap/." "${CT}:/config/plugins/LDAP Authentication_${JELLYFIN_LDAP_PLUGIN_VERSION}"
rm -rf "${tmp}"
log "installed LDAP plugin ${JELLYFIN_LDAP_PLUGIN_VERSION} from GitHub release"
}
write_config() {
container exec -i "${CT}" sh -c "mkdir -p /config/plugins/configurations && cat > /config/plugins/configurations/$1"
}
if [ "${PHASE}" = "install" ]; then
wait_up
seed_admin_and_get_token || {
log "ERROR: the administrator cannot authenticate after wizard retries; the deployed password does not match the running server"
exit 1
}
if [ -n "${TOKEN:-}" ] && [ "${JELLYFIN_SSO_ENABLED}" = "true" ]; then
${CURL} -fsS -X POST "${API}/Repositories" -H "Content-Type: application/json" \
-H "Authorization: MediaBrowser Token=\"${TOKEN}\"" \
-d "[{\"Name\":\"jellyfin-plugin-sso\",\"Url\":\"${JELLYFIN_SSO_PLUGIN_MANIFEST}\",\"Enabled\":true}]" >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
fi
[ "${JELLYFIN_LDAP_ENABLED}" = "true" ] && install_ldap_plugin
[ -n "${TOKEN:-}" ] && [ "${JELLYFIN_SSO_ENABLED}" = "true" ] && install_plugin "SSO%20Authentication"
sleep 10
log "plugin install phase complete (restart is driven by the calling task)"
exit 0
fi
if [ "${PHASE}" != "configure" ]; then
log "ERROR: unknown JELLYFIN_PHASE '${PHASE}' (expected install|configure)"
exit 1
fi
wait_up
seed_admin_and_get_token || {
log "ERROR: the administrator cannot authenticate after wizard retries; the deployed password does not match the running server"
exit 1
}
if [ "${JELLYFIN_LDAP_ENABLED}" = "true" ]; then
write_config "LDAP-Auth.xml" <<XML
<?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">
<LdapServer>${JELLYFIN_LDAP_SERVER}</LdapServer>
<LdapPort>${JELLYFIN_LDAP_PORT}</LdapPort>
<UseSsl>false</UseSsl>
<UseStartTls>false</UseStartTls>
<SkipSslVerify>false</SkipSslVerify>
<LdapBindUser>${JELLYFIN_LDAP_BIND_DN}</LdapBindUser>
<LdapBindPassword>${JELLYFIN_LDAP_BIND_PASSWORD}</LdapBindPassword>
<LdapBaseDn>${JELLYFIN_LDAP_BASE_DN}</LdapBaseDn>
<LdapSearchFilter>(objectClass=inetOrgPerson)</LdapSearchFilter>
<LdapSearchAttributes>uid, cn, mail, displayName</LdapSearchAttributes>
<CreateUsersFromLdap>true</CreateUsersFromLdap>
<LdapUidAttribute>${JELLYFIN_LDAP_UID_ATTR}</LdapUidAttribute>
<LdapUsernameAttribute>${JELLYFIN_LDAP_UID_ATTR}</LdapUsernameAttribute>
<LdapPasswordAttribute>userPassword</LdapPasswordAttribute>
<EnableAllFolders>true</EnableAllFolders>
</PluginConfiguration>
XML
container exec -i "${CT}" sh -c "python3 -c \"import xml.dom.minidom,sys; xml.dom.minidom.parse('/config/plugins/configurations/LDAP-Auth.xml')\"" 2>/dev/null \
|| log "WARN: LDAP-Auth.xml not parseable by python (still written); confirm on live deploy"
fi
if [ -n "${TOKEN:-}" ] && [ "${JELLYFIN_SSO_ENABLED}" = "true" ]; then
SSO_PLUGIN_GUID="505ce9d1-d916-42fa-86ca-673ef241d7df"
if ${CURL} -fsS -X POST "${API}/Plugins/${SSO_PLUGIN_GUID}/Configuration" \
-H "Content-Type: application/json" \
-H "Authorization: MediaBrowser Token=\"${TOKEN}\"" --data @- >/dev/null 2>&1 <<JSON
{
"SamlConfigs": {},
"OidConfigs": {
"Keycloak": {
"OidEndpoint": "${JELLYFIN_OIDC_ISSUER}",
"OidClientId": "${JELLYFIN_OIDC_CLIENT_ID}",
"OidSecret": "${JELLYFIN_OIDC_CLIENT_SECRET}",
"Enabled": true,
"EnableAuthorization": false,
"EnableAllFolders": true,
"EnabledFolders": [],
"Roles": [],
"AdminRoles": [],
"EnableFolderRoles": false,
"EnableLiveTv": false,
"EnableLiveTvManagement": false,
"LiveTvRoles": [],
"LiveTvManagementRoles": [],
"FolderRoleMapping": [],
"RoleClaim": "realm_access.roles",
"OidScopes": ["openid", "profile", "email"],
"DefaultUsernameClaim": "preferred_username",
"DisablePushedAuthorization": true,
"SchemeOverride": "https"
}
}
}
JSON
then
log "configured SSO OIDC provider (Keycloak)"
else
log "WARN: failed to configure SSO OIDC provider"
fi
fi
if [ -n "${TOKEN:-}" ] && [ "${JELLYFIN_SSO_ENABLED}" = "true" ]; then
if ${CURL} -fsS -X POST "${API}/System/Configuration/branding" -H "Content-Type: application/json" \
-H "Authorization: MediaBrowser Token=\"${TOKEN}\"" \
-d '{"LoginDisclaimer":"<form action=\"/sso/OID/start/Keycloak\"><button class=\"raised block emby-button button-submit\">Sign in with Keycloak</button></form>","CustomCss":"a.raised.emby-button { padding: 0.9em 1em; color: inherit !important; } .disclaimerContainer { display: block; }","SplashscreenEnabled":false}' >/dev/null 2>&1; then
log "configured SSO login button via branding"
else
log "WARN: failed to set SSO login branding"
fi
fi
log "auth provisioning complete (final restart is driven by the calling task)"