mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
This commit is contained in:
1 parent
c750247e33
commit
a8469e0642
2 files changed
+81
-14
No files matched your search
@@ -28,22 +28,25 @@ complete_wizard() {
|
||||
${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
|
||||
}
|
||||
|
||||
get_token() {
|
||||
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}}\"}" \
|
||||
| sed -n 's/.*"AccessToken":"\([^"]*\)".*/\1/p'
|
||||
-d "{\"Username\":\"${JELLYFIN_ADMIN_USERNAME}\",\"Pw\":\"${1-${JELLYFIN_ADMIN_PASSWORD}}\"}"
|
||||
}
|
||||
|
||||
get_user_id() {
|
||||
${CURL} -fsS -X POST "${API}/Users/AuthenticateByName" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
|
||||
-d "{\"Username\":\"${JELLYFIN_ADMIN_USERNAME}\",\"Pw\":\"$1\"}" \
|
||||
| sed -n 's/.*"User":{[^}]*"Id":"\([^"]*\)".*/\1/p'
|
||||
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
|
||||
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 || return 1
|
||||
-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
|
||||
@@ -51,16 +54,23 @@ realign_admin_password() {
|
||||
return 1
|
||||
fi
|
||||
${CURL} -fsS -X POST "${API}/Users/ForgotPassword/Pin" -H "Content-Type: application/json" -H "${CLIENT_HDR}" \
|
||||
-d "{\"Pin\":\"${pin}\"}" >/dev/null || return 1
|
||||
pin_token="$(get_token "${pin}")" || pin_token=""
|
||||
user_id="$(get_user_id "${pin}")" || user_id=""
|
||||
-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: the administrator does not authenticate with the reset pin"
|
||||
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 || return 1
|
||||
-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"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from utils import PROJECT_ROOT
|
||||
|
||||
SCRIPT = PROJECT_ROOT / "roles/web-app-jellyfin/files/shell/configure-auth.sh"
|
||||
|
||||
AUTH_RESPONSE = (
|
||||
'{"User":{"Name":"administrator","ServerId":"e5c","Id":"a1b2c3d4",'
|
||||
'"HasPassword":true,"Configuration":{"PlayDefaultAudioTrack":true},'
|
||||
'"Policy":{"IsAdministrator":true}},"SessionInfo":{"Id":"s1"},'
|
||||
'"AccessToken":"tok-abc-123","ServerId":"e5c"}'
|
||||
)
|
||||
|
||||
_SED = re.compile(r"sed -n '(s/\.\*\"(?:AccessToken|User)\".*?/p)'")
|
||||
|
||||
|
||||
def _apply(expression: str, payload: str) -> str:
|
||||
done = subprocess.run(
|
||||
["sed", "-n", expression],
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return done.stdout.strip()
|
||||
|
||||
|
||||
class TestConfigureAuthExtraction(unittest.TestCase):
|
||||
"""One AuthenticateByName response must yield both the token and the user id.
|
||||
|
||||
Authenticating twice with the fixed DeviceId in CLIENT_HDR makes Jellyfin
|
||||
revoke the first session, so the token from an earlier call is already dead
|
||||
when the password change presents it.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.source = SCRIPT.read_text()
|
||||
self.expressions = _SED.findall(self.source)
|
||||
|
||||
def test_the_script_keeps_exactly_one_authenticate_call_site_per_field(self):
|
||||
self.assertEqual(self.source.count("Users/AuthenticateByName"), 1)
|
||||
|
||||
def test_one_response_yields_both_the_token_and_the_user_id(self):
|
||||
self.assertEqual(len(self.expressions), 2)
|
||||
extracted = {_apply(expression, AUTH_RESPONSE) for expression in self.expressions}
|
||||
self.assertEqual(extracted, {"tok-abc-123", "a1b2c3d4"})
|
||||
|
||||
def test_a_response_without_a_token_extracts_nothing(self):
|
||||
for expression in self.expressions:
|
||||
with self.subTest(expression=expression):
|
||||
self.assertEqual(_apply(expression, '{"Error":"Unauthorized"}'), "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user