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
```