mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
233 lines
10 KiB
Bash
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)"
|