### Start TOTP setup Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Starts TOTP enrollment by calling TwoFactorAuth/Setup/Totp with optimistic step-up, then displays the QR code and manual entry key. Handles 403 step-up failures with a wrong-code alert. ```javascript document.getElementById('btnStartSetup').addEventListener('click', async function() { // [v2.5.6] (round-5d): optimistic step-up. Try without code first, // server prompts only if hardened security says it's needed. try { var result = await mutateWithStepUp('POST', 'TwoFactorAuth/Setup/Totp', {}, { message: _tr('tfa.setup.stepup.totp_msg', 'Enter your current TOTP or recovery code to start a new TOTP enrollment.') }); document.getElementById('qrImg').src = 'data:image/png;base64,' + (result.qrCodeBase64 || result.QrCodeBase64); document.getElementById('secretKey').textContent = result.manualEntryKey || result.ManualEntryKey; document.getElementById('enrollStep1').style.display = 'none'; document.getElementById('enrollStep2').style.display = 'block'; document.getElementById('confirmCode').focus(); } catch (e) { if (e && e.cancelled) return; if (e && e.status === 403 && e.body && (e.body.stepUpRequired || e.body.twoFactorRequired)) { alert(_tr('tfa.setup.stepup.wrong_code', 'That code did not verify. Try again.')); } else { alert(_tr('tfa.setup.enroll.start_failed', 'Failed to start TOTP setup.')); } } }); ``` -------------------------------- ### Initialize TFA setup page and render all sections Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Initializes the Two-Factor Authentication setup page by computing user email, step-up mode, and factor status, then renders all UI sections and loads SSO links and IP allowlist. Sets global flags like window.__tfa_userHasEmail and window.__tfa_stepUpMode for later use. ```javascript var emailRow = r[4] || {}; var userEmail = emailRow.email || emailRow.Email || ''; var publicCfg = window._tfaPublicConfig || {}; window.__tfa_userHasEmail = !!userEmail && (publicCfg.emailOtpEnabled !== false); var hasFactor = !!totpOn || passkeyCount > 0; var stepUpMode = (myStatus && (myStatus.selfServiceStepUpMode || myStatus.SelfServiceStepUpMode)) || 'Forced'; var userOptedIn = !!(myStatus && (myStatus.requireStepUpForChanges || myStatus.RequireStepUpForChanges)); window.__tfa_stepUpMode = stepUpMode; window.__tfa_userOptedIn = userOptedIn; var modeRequires = stepUpMode === 'Forced' || (stepUpMode === 'UserChoice' && userOptedIn); window.__tfa_userHasFactor = hasFactor && modeRequires; renderStepUpToggle(stepUpMode, userOptedIn); renderStatus(me, totpOn); renderEnroll(totpOn); renderRecovery(totpOn, r[3]); renderEmail(r[4]); renderPending(r[7]); renderPaired(totpOn, r[6]); renderAppPasswords(totpOn, r[5]); renderTrusted(r[2]); renderSessions(r[8]); document.getElementById('cardDisable').style.display = totpOn ? 'block' : 'none'; loadSsoLinks(); loadIpAllowlist(); }); } ``` -------------------------------- ### Setup page initialization: loadAll, public config fetch, and i18n race fix Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html These JavaScript snippets are part of the setup page's initialization logic. The first snippet calls the original loadAll function, fetches the TwoFactorAuth/Setup/Status endpoint to conditionally show QR pairing and TOTP rotation cards, and attempts to load passkeys, displaying an error message if loading fails. The second snippet fetches the public configuration from TwoFactorAuth/public-config and stores it on window._tfaPublicConfig for later use. The third snippet waits for the i18n bundle and the public config promise to resolve before calling loadAll(), preventing a race condition where translations might not be applied. ```javascript try { origLoadAll(); } catch (e) { try { console.error('\[2FA\] loadAll failed', e); } catch (e2) {} } api('GET', 'TwoFactorAuth/Setup/Status').then(function(s) { var enabled = s && (s.totpEnabled || s.TotpEnabled); document.getElementById('cardQrPair').style.display = enabled ? '' : 'none'; document.getElementById('cardTotpRotate').style.display = enabled ? '' : 'none'; }).catch(function(){}); try { loadPasskeys(); } catch (e) { try { console.error('\[2FA\] loadPasskeys failed', e); } catch (e2) {} var box = document.getElementById('passkeyList'); if (box) box.innerHTML = '' + _tr('tfa.setup.passkeys.load_failed', 'Failed to load passkeys.') + ''; } }; ``` ```javascript window._tfaPublicConfig = null; var publicConfigPromise = fetch(window.tfaUrl('TwoFactorAuth/public-config')) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(c) { window._tfaPublicConfig = c || {}; }) .catch(function() { window._tfaPublicConfig = {}; }); ``` ```javascript Promise.all([ (window.tfaI18n && window.tfaI18n.ready ? window.tfaI18n.ready : Promise.resolve()), publicConfigPromise, ]).then(function() { loadAll(); }); ``` -------------------------------- ### Bring up the lab with docker compose Source: https://github.com/zl154/jellyfinsecurity/blob/main/test-lab/README.md Bring up the lab in tiers. Tier 1 starts Jellyfin with the plugin; Tier 2 adds Keycloak for OIDC; Tier 3 adds the hostile proxy. Use `podman compose` instead of `docker compose` if using Podman Desktop. ```powershell cd F:\tmp\JellyfinSecurity\test-lab # Tier 1 — Jellyfin + plugin only (2FA, passkeys, app-passwords, set-password page) pwsh ./stage-plugin.ps1 # builds the plugin + stages it onto G: docker compose up -d jellyfin # Tier 2 — add real OIDC (group→library, group→admin, force-password, #103/#104) docker compose up -d jellyfin keycloak # Tier 3 — add the hostile proxy (#95, proxy side of #102) docker compose up -d ``` -------------------------------- ### loadAll wrapper for passkeys Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Wraps the original loadAll function to ensure that loadPasskeys runs even if the original setup fails synchronously, preventing the placeholder text from remaining. ```javascript var origLoadAll = loadAll; loadAll = function() { // [v2.5.6] (round-5 hardening): wrap origLoadAll in try so any // synchronous failure in its setup doesn't prevent loadPasskeys // from running and replacing the placeholder "Loading…" text. ``` -------------------------------- ### Move accounts to default provider with sqlite3 Source: https://github.com/zl154/jellyfinsecurity/blob/main/README.md Back up the Jellyfin database, list accounts on the plugin's provider, and move them to Jellyfin's default provider when no administrator can sign in. Stop Jellyfin before running these commands and start it again afterwards. ```bash # In the official Docker image the database is data/jellyfin.db inside the folder mounted at /config. # Elsewhere, find it with: find / -name jellyfin.db 2>/dev/null cp /path/to/jellyfin.db* /path/to/backup/ # The accounts on the plugin's provider sqlite3 /path/to/jellyfin.db "SELECT Username FROM Users WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider';" # Move them to Jellyfin's own provider; prints how many were moved sqlite3 /path/to/jellyfin.db "UPDATE Users SET AuthenticationProviderId = 'Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider' WHERE AuthenticationProviderId = 'Jellyfin.Plugin.TwoFactorAuth.Services.TwoFactorAuthProvider'; SELECT changes();" ``` -------------------------------- ### Create app password handler (truncated) Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Starts the app password creation handler. It reads the label input and begins an async function that will use optimistic step-up (as noted in the comment). The snippet is truncated in the source, so the full logic is not shown. ```javascript document.getElementById('btnCreateAp').addEventListener('click', async function() { var label = document.getElementById('apLabel').value.trim(); // [v2.5.6] (round-5d): optimistic step-up. ``` -------------------------------- ### Find user GUID via sqlite3 Source: https://github.com/zl154/jellyfinsecurity/blob/main/RESET.md Run this sqlite3 command inside the Jellyfin Docker container or with sqlite3 installed locally to list user IDs and usernames. The Id column is the GUID needed for the reset procedure. ```bash sqlite3 /data/jellyfin.db \ "SELECT Id, Username FROM Users;" ``` -------------------------------- ### Run reset-user.sh script Source: https://github.com/zl154/jellyfinsecurity/blob/main/RESET.md Executes the reset-user.sh script with the Jellyfin config path and user GUID. The script stops Jellyfin, renames the user's plugin data file to .disabled-, and starts Jellyfin. ```bash ./reset-user.sh ``` -------------------------------- ### Cancel TOTP setup Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Cancels TOTP setup by calling TwoFactorAuth/Setup/Disable and resets the enrollment UI back to step 1. ```javascript document.getElementById('btnCancelSetup').addEventListener('click', function() { api('POST', 'TwoFactorAuth/Setup/Disable').then(function() { document.getElementById('enrollStep1').style.display = 'block'; document.getElementById('enrollStep2').style.display = 'none'; document.getElementById('confirmCode').value = ''; }); }); ``` -------------------------------- ### Run full test suite Source: https://github.com/zl154/jellyfinsecurity/blob/main/docs/superpowers/plans/2026-05-28-v2.5.0-phase1-security-hardening.md Run the full test suite in Release configuration. Expected: all tests pass (181 prior plus new StepUp/ChallengeStore tests). ```bash dotnet test tests/Jellyfin.Plugin.TwoFactorAuth.Tests/Jellyfin.Plugin.TwoFactorAuth.Tests.csproj -c Release --nologo ``` -------------------------------- ### GET endpoint for user preferences Source: https://github.com/zl154/jellyfinsecurity/blob/main/docs/superpowers/plans/2026-05-28-v2.5.0-phase4-i18n.md Add this GET endpoint to TwoFactorAuthController.cs. It allows self-read or admin access, returns language settings, and uses the IsAuthorizedForUser helper. ```csharp [HttpGet("users/{userId:guid}/preferences")] [Authorize] public async Task GetUserPreferences([FromRoute] Guid userId) { // Allow self-read; admins can read anyone. if (!IsAuthorizedForUser(userId)) return Forbid(); var data = await _store.GetUserDataAsync(userId).ConfigureAwait(false); var cfg = Plugin.Instance?.Configuration ?? new Jellyfin.Plugin.TwoFactorAuth.Configuration.PluginConfiguration(); return Ok(new { language = data.Language, effectiveLanguage = data.Language ?? cfg.DefaultLanguage, defaultLanguage = cfg.DefaultLanguage }); } ``` -------------------------------- ### Initial flow dispatch: load policy or begin session validation Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setpassword.html Determines the initial action based on presence of token and onboardingProof. If both are present, loads the policy; if only token, begins session validation; otherwise returns to stock login. ```javascript if (token && onboardingProof) loadPolicy(0); else if (token) beginSessionValidation(); else returnToStockLogin(); ``` -------------------------------- ### beginSessionValidation function: start OIDC validation Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setpassword.html Sends a POST request to start OIDC onboarding validation. On success, redirects to the returned auth URL. On failure, returns to stock login. ```javascript function beginSessionValidation() { fetch('../TwoFactorAuth/Oidc/OnboardingValidationBegin', { method: 'POST', headers: authHeaders(), credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (body) { if (!body || !body.authUrl) throw new Error('Missing validation URL'); window.location.replace(body.authUrl); }).catch(function () { returnToStockLogin(); }); } ``` -------------------------------- ### Add passkey with WebAuthn and step-up Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html Handles the add-passkey button click. Checks for a secure context and WebAuthn support, then calls RegisterBegin with optimistic step-up, creates a credential via navigator.credentials.create, and submits the result to RegisterFinish. On failure, it ignores user-cancelled step-up prompts and surfaces the DOMException name. ```javascript document.getElementById('btnAddPasskey').addEventListener('click', async function() { var ok = document.getElementById('passkeySuccess'), err = document.getElementById('passkeyError'); ok.classList.remove('show'); err.classList.remove('show'); // PublicKeyCredential is undefined when the page is served over // plain HTTP from a non-localhost origin — mobile browsers refuse // to expose WebAuthn unless isSecureContext is true. The user // typically hit 192.168.x.y:port directly; tell them to use the // HTTPS / Cloudflare URL instead. if (!window.isSecureContext) { err.textContent = _tr('tfa.setup.passkeys.err_https', 'Passkeys require HTTPS. Reload this page via your HTTPS / proxied URL (not the bare LAN IP) and try again.'); err.classList.add('show'); return; } if (!window.PublicKeyCredential) { err.textContent = _tr('tfa.setup.passkeys.err_webauthn', 'This browser does not expose WebAuthn. Try Safari (iOS), Chrome/Edge (Android/desktop), or Firefox 60+.'); err.classList.add('show'); return; } // Some browsers expose PublicKeyCredential but lack platform // authenticator support — gracefully advertise security keys only. try { if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) { var avail = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); if (!avail) console.log('[2FA] No platform authenticator — security key flow only'); } } catch (e) {} try { // [v2.5.6] (round-5d): optimistic step-up for RegisterBegin. // Try without code first — server gates only when hardened // security is configured. On 403 stepUpRequired, prompt and // retry once. var begin = await mutateWithStepUp('POST', 'TwoFactorAuth/Passkeys/RegisterBegin', {}, { message: _tr('tfa.setup.stepup.passkey_add_msg', 'Enter your current TOTP or recovery code to add a passkey.') }); var opts = begin.options; opts.challenge = b64uToBytes(opts.challenge); opts.user.id = b64uToBytes(opts.user.id); if (opts.excludeCredentials) opts.excludeCredentials.forEach(function(c){ c.id = b64uToBytes(c.id); }); var cred = await navigator.credentials.create({ publicKey: opts }); var resp = { id: cred.id, rawId: bytesToB64u(cred.rawId), type: cred.type, response: { attestationObject: bytesToB64u(cred.response.attestationObject), clientDataJSON: bytesToB64u(cred.response.clientDataJSON), }, extensions: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, }; var label = document.getElementById('passkeyLabel').value.trim(); await api('POST', 'TwoFactorAuth/Passkeys/RegisterFinish', { nonce: begin.nonce, response: JSON.stringify(resp), label: label }); ok.textContent = _tr('tfa.setup.passkeys.added', 'Passkey added.'); ok.classList.add('show'); document.getElementById('passkeyLabel').value = ''; loadPasskeys(); } catch (e) { // [v2.5.6] (round-5d): user-cancelled the step-up prompt is // a normal flow, not an error. if (e && e.cancelled) return; // Surface DOMException name (NotAllowedError, SecurityError, // NotSupportedError, InvalidStateError) — Apple's blanket // "request is not allowed" message hides the real cause. ``` -------------------------------- ### Add GET Config/Export endpoint Source: https://github.com/zl154/jellyfinsecurity/blob/main/docs/superpowers/plans/2026-05-28-v2.5.0-phase3-config-export-import.md Adds the GET Config/Export endpoint to TwoFactorAuthController.cs. Requires the RequiresElevation policy. When includeSecrets is true, it performs step-up gating and requires a passphrase; otherwise it exports config-only data. Sets Content-Disposition attachment filenames with UTC timestamps. ```csharp [HttpGet("Config/Export")] [Authorize(Policy = "RequiresElevation")] public async Task ExportConfig([FromQuery] bool includeSecrets = false, [FromQuery] string? passphrase = null) { if (includeSecrets) { var guard = StepUpGuard(StepUpAction.ExportFullConfig); if (guard is not null) return guard; if (string.IsNullOrEmpty(passphrase)) return BadRequest(new { message = "passphrase required for full export" }); var fullEnv = await _export.BuildFullExportAsync(passphrase).ConfigureAwait(false); var json = System.Text.Json.JsonSerializer.Serialize(fullEnv, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); Response.Headers["Content-Disposition"] = $"attachment; filename=2fa-export-full-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json"; return Content(json, "application/json"); } var env = await _export.BuildConfigOnlyExportAsync().ConfigureAwait(false); var plainJson = System.Text.Json.JsonSerializer.Serialize(env, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); Response.Headers["Content-Disposition"] = $"attachment; filename=2fa-export-config-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json"; return Content(plainJson, "application/json"); } ``` -------------------------------- ### Build plugin project Source: https://github.com/zl154/jellyfinsecurity/blob/main/docs/superpowers/plans/2026-05-28-v2.5.0-phase1-security-hardening.md Builds the plugin project in Release configuration to verify the registration compiles. Expected output: Build succeeded, 0 errors. ```bash dotnet build src/Jellyfin.Plugin.TwoFactorAuth/Jellyfin.Plugin.TwoFactorAuth.csproj -c Release ``` -------------------------------- ### Two-Factor Authentication Setup Page JavaScript Source: https://github.com/zl154/jellyfinsecurity/blob/main/src/Jellyfin.Plugin.TwoFactorAuth/Pages/setup.html JavaScript code for the Two-Factor Authentication setup page. It handles OIDC linking, IP allowlist management, status rendering, recovery code display, email input, and step-up toggle rendering. The code uses the `api` helper for HTTP requests and `_tr` for translations. ```javascript avail.querySelectorAll('\[data-link-prov\]').forEach(function(btn) { btn.addEventListener('click', function() { var pid = btn.getAttribute('data-link-prov'); api('POST', 'TwoFactorAuth/Oidc/LinkBegin/' + encodeURIComponent(pid)) .then(function(res) { if (!res || !res.authUrl) { return; } var pop = window.open(res.authUrl, 'tfaLink', 'width=520,height=680'); function onMsg(ev) { if (ev.origin !== window.location.origin) return; var d = ev.data || {}; if (d.type !== 'tfa-link-oidc') return; window.removeEventListener('message', onMsg); try { if (pop) pop.close(); } catch (e) {} if (d.success) { loadSsoLinks(); } else { alert(_tr('tfa.setup.sso.link_failed', 'Linking failed') + (d.message ? ': ' + d.message : '')); } } window.addEventListener('message', onMsg); }) .catch(function(err) { alert(_tr('tfa.setup.sso.link_failed', 'Could not start linking') + ': ' + (err.message || '')); }); }); }); } }); } // ---- v2.0 PER-USER IP ALLOWLIST ---- function loadIpAllowlist() { api('GET', 'TwoFactorAuth/IpAllowlist').then(function(cidrs) { document.getElementById('ipAllowlistInput').value = (cidrs || []).join('\n'); }).catch(function() { /* silent */ }); } document.getElementById('btnSaveAllowlist').addEventListener('click', function() { var lines = document.getElementById('ipAllowlistInput').value .split('\n').map(function(s) { return s.trim(); }).filter(Boolean); var status = document.getElementById('allowlistStatus'); status.style.color = '#888'; status.textContent = _tr('tfa.admin.common.saving', 'Saving…'); api('PUT', 'TwoFactorAuth/IpAllowlist', { Cidrs: lines }).then(function() { status.style.color = '#4caf50'; status.textContent = '✓ ' + _tr('tfa.setup.allowlist.saved', 'Saved'); setTimeout(function() { status.textContent = ''; }, 2000); }).catch(function(err) { status.style.color = '#f44336'; status.textContent = '✗ ' + (err.message || _tr('tfa.admin.common.save_failed', 'Save failed')); }); }); function renderStatus(me, totpOn) { var pill = document.getElementById('statusPill'); // [v2.5.6] (round-5e): strip data-i18n-key so applyTranslations // doesn't reset the pill back to "Loading…". pill.removeAttribute('data-i18n-key'); pill.className = 'pill ' + (totpOn ? 'on' : 'off'); pill.textContent = totpOn ? _tr('tfa.setup.status.pill_on', '2FA Enabled') : _tr('tfa.setup.status.pill_off', 'Not Set Up'); document.getElementById('statusContent').innerHTML = '
' + _tr('tfa.setup.status.signed_in_as', 'Signed in as') + ' ' + escapeHtml(me.Name) + '. ' + (totpOn ? _tr('tfa.setup.status.protected', 'Your account is protected by an authenticator app.') : '' + _tr('tfa.setup.status.not_protected', 'Your account is not yet protected. Set up TOTP below.') + '') + '
'; } function renderEnroll(totpOn) { document.getElementById('cardEnroll').style.display = totpOn ? 'none' : 'block'; } function renderRecovery(totpOn, rc) { document.getElementById('cardRecovery').style.display = totpOn ? 'block' : 'none'; var pill = document.getElementById('recoveryPill'); var note = document.getElementById('recoveryNote'); var btn = document.getElementById('btnGenerateCodes'); if (!rc.total) { pill.outerHTML = '' + _tr('tfa.setup.recovery.pill_none', 'None') + ''; note.textContent = _tr('tfa.setup.recovery.note_none', 'No recovery codes yet — generate some now.'); btn.textContent = _tr('tfa.setup.recovery.btn_generate', 'Generate Recovery Codes'); } else { var pillCls = rc.remaining < 3 ? 'warn' : (rc.remaining > 0 ? 'on' : 'off'); pill.outerHTML = '' + rc.remaining + ' ' + _tr('tfa.setup.recovery.left_suffix', 'left') + ''; note.textContent = rc.remaining + '/' + rc.total + ' ' + _tr('tfa.setup.recovery.remaining_generated_prefix', 'remaining (generated') + ' ' + new Date(rc.generatedAt).toLocaleDateString() + ')'; btn.textContent = _tr('tfa.setup.recovery.btn_regenerate', 'Regenerate (invalidates old)'); } } function renderEmail(r) { document.getElementById('emailInput').value = r.email || ''; } // [v2.5.6] (round-5 fix D): render the user-facing "Hardened Security" // toggle. Card is only visible when the admin's SelfServiceStepUpMode // is UserChoice — Off hides the option entirely (admin said no // prompts), Forced hides it because the user can't opt out (server // enforces regardless). function renderStepUpToggle(mode, optedIn) { // [v2.5.6] (round-5e): log so the user can verify the server's // reported mode in browser devtools. ``` -------------------------------- ### Build plugin: dotnet build Source: https://github.com/zl154/jellyfinsecurity/blob/main/docs/superpowers/plans/2026-05-28-v2.5.0-phase4-i18n.md Build the plugin project to confirm the new helper compiles cleanly. ```bash dotnet build src/Jellyfin.Plugin.TwoFactorAuth/Jellyfin.Plugin.TwoFactorAuth.csproj --no-restore ``` -------------------------------- ### Add keycloak.test to hosts file Source: https://github.com/zl154/jellyfinsecurity/blob/main/test-lab/README.md Add this line to the hosts file (C:\Windows\System32\drivers\etc\hosts) so the OIDC issuer URL is identical for the browser and the Jellyfin container. Run Notepad as admin to edit the file. ```hosts 127.0.0.1 keycloak.test ``` -------------------------------- ### Add Jellyfin Security repository URL Source: https://github.com/zl154/jellyfinsecurity/wiki/Installation Add this URL as a repository in Jellyfin's plugin settings to install Jellyfin Security from the catalogue. Requires Jellyfin 10.11+. ```text https://raw.githubusercontent.com/ZL154/JellyfinSecurity/main/manifest.json ```