diff --git a/flake.nix b/flake.nix
index 6b46d5e..bdf225a 100644
--- a/flake.nix
+++ b/flake.nix
@@ -39,7 +39,7 @@
packages = developmentPackages ++ [ pkgs.playwright-driver ];
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
- withFirefox = false;
+ withFirefox = true;
withWebkit = false;
withFfmpeg = false;
};
diff --git a/www/README.md b/www/README.md
index 50bbe6d..8277ff3 100644
--- a/www/README.md
+++ b/www/README.md
@@ -619,7 +619,13 @@ dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. The server-side installer is a root-only
POSIX shell script using NixOS base tools; it does not require Node.js on
Contabo. Ordinary releases never copy employee documents into `dist`;
-protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is
+protected staff and management resources remain under server-controlled Basic
+Auth namespaces. After a successful unlock, the page keeps that role's code
+only in memory and uses it for same-origin authenticated document fetches. The
+response is downloaded as a local Blob, so employees do not receive a second
+browser authentication prompt. Cross-role requests are stopped before
+credentials are sent; direct resource URLs still require nginx authentication.
+Codes are never put in URLs or browser storage. The accepted interim portal is
explicitly non-PHI.
### Google Ads consultation-form conversion handoff
diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md
index 3b9af83..b446654 100644
--- a/www/integrations/employee-portal/README.md
+++ b/www/integrations/employee-portal/README.md
@@ -5,6 +5,16 @@ staff and management portal content and the interim shared-code presentation.
Browser-side decryption remains access friction rather than individual
authentication, revocation, a secure session, or a HIPAA determination.
+The same successfully validated code authorizes protected document downloads
+for that role during the current page session. The browser keeps it only in a
+closure, performs a same-origin authenticated fetch, and saves the returned
+document without opening nginx's native Basic Auth dialog. Staff uses the
+`staff` username and stream `b`; management uses the `management` username and
+stream `a`. Direct URLs remain nginx-protected, cross-role fetches are rejected
+before credentials are sent, and failed authenticated downloads show an inline
+error without direct-navigation fallback. No code enters a URL, browser
+storage, public artifact, or log.
+
## Approved production ciphertext
An indexing-enabled production build may include one owner-approved encrypted
diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md
index bcec0a7..46a136d 100644
--- a/www/integrations/nixos/deploy.md
+++ b/www/integrations/nixos/deploy.md
@@ -92,3 +92,13 @@ resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal
shared-code rotation is coordinated: update VPS SOPS inputs and privately
re-encrypt the browser payload together. This interim portal is non-PHI and is
not an employee identity system.
+
+The portal uses one code entry per page session. A successful decrypt retains
+the code only in page memory and sends the matching fixed username (`staff` or
+`management`) in an explicit same-origin Basic Authorization header when a
+protected link is clicked. It downloads the authenticated response as a Blob
+and never navigates directly to the protected URL after an error. Verify that
+ordinary clicks download without a native credential dialog, inline errors
+appear for denied or unavailable files, direct unauthenticated requests remain
+401, and neither role can request the other namespace. Reloading, leaving the
+page, or an Astro page swap discards the in-memory credential.
diff --git a/www/src/components/pages/EmployeePortal.astro b/www/src/components/pages/EmployeePortal.astro
index d3a28de..a3f23f0 100644
--- a/www/src/components/pages/EmployeePortal.astro
+++ b/www/src/components/pages/EmployeePortal.astro
@@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.
+
@@ -35,6 +36,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content:focus { outline: none; }
+ .employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
[hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
diff --git a/www/src/scripts/employee-portal.ts b/www/src/scripts/employee-portal.ts
index 3d9832a..78ab3d3 100644
--- a/www/src/scripts/employee-portal.ts
+++ b/www/src/scripts/employee-portal.ts
@@ -1,4 +1,5 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
+import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
import { portalLinkPresentation } from '../utils/portal-presentation';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media,
@@ -24,6 +25,8 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation;
+ const resource = protectedPortalResource(url.href, location.origin);
+ if (resource) element.dataset.portalResourceRole = resource.role;
}
} catch { /* Invalid destination remains visible text without navigation. */ }
}
@@ -99,6 +102,9 @@ function initialize() {
const feedback = root.querySelector('[data-portal-feedback]')!;
const prompt = root.querySelector('[data-portal-prompt]')!;
const content = root.querySelector('[data-portal-content]')!;
+ const downloadFeedback = root.querySelector('[data-portal-download-feedback]')!;
+ let credential: { role: 'staff' | 'management'; code: string } | null = null;
+ let downloading = false;
let data: ReturnType;
try {
if (!crypto.subtle) throw new Error();
@@ -106,6 +112,45 @@ function initialize() {
input.disabled = false; button.disabled = false;
feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
+ const clearSession = () => {
+ credential = null;
+ input.value = '';
+ content.replaceChildren();
+ content.hidden = true;
+ prompt.hidden = false;
+ downloadFeedback.hidden = true;
+ downloadFeedback.textContent = '';
+ };
+ const saveDownload = (blob: Blob, filename: string) => {
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = objectUrl; link.download = filename; link.hidden = true;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
+ };
+ content.addEventListener('click', (event) => {
+ const target = event.target instanceof Element ? event.target.closest('a[data-portal-resource-role]') : null;
+ if (!target) return;
+ event.preventDefault();
+ if (downloading) return;
+ const resource = protectedPortalResource(target.href, location.origin);
+ if (!resource || !credential) {
+ downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
+ downloadFeedback.hidden = false;
+ return;
+ }
+ downloading = true;
+ target.setAttribute('aria-disabled', 'true');
+ downloadFeedback.textContent = 'Downloading document…';
+ downloadFeedback.hidden = false;
+ void fetchProtectedPortalResource(resource, credential)
+ .then(({ blob, filename }) => {
+ saveDownload(blob, filename);
+ downloadFeedback.textContent = `Downloaded ${filename}.`;
+ })
+ .catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
+ .finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
+ });
const unlock = async () => {
if (button.disabled) return;
button.disabled = true;
@@ -116,8 +161,12 @@ function initialize() {
for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass);
if (plain !== null) {
+ const role = portalRoleForStreamId(stream.id);
+ if (!role) throw new Error('Unknown portal stream');
+ credential = { role, code: pass };
content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false;
+ downloadFeedback.hidden = true; downloadFeedback.textContent = '';
input.value = ''; content.focus(); return;
}
}
@@ -127,8 +176,9 @@ function initialize() {
};
button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
- // BFCache must not restore an already unlocked view.
- addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
+ // Credentials exist only in this page closure and are discarded on exit/swap.
+ addEventListener('pagehide', clearSession, { once: true });
+ document.addEventListener('astro:before-swap', clearSession, { once: true });
}
initialize();
document.addEventListener('astro:page-load', initialize);
diff --git a/www/src/utils/portal-download.mjs b/www/src/utils/portal-download.mjs
new file mode 100644
index 0000000..28dada5
--- /dev/null
+++ b/www/src/utils/portal-download.mjs
@@ -0,0 +1,70 @@
+const roles = new Set(['staff', 'management']);
+const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
+
+export function portalRoleForStreamId(id) {
+ if (id === 'b') return 'staff';
+ if (id === 'a') return 'management';
+ return undefined;
+}
+
+export function protectedPortalResource(href, origin) {
+ let url;
+ try { url = new URL(href, origin); } catch { return undefined; }
+ if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
+ const match = resourcePath.exec(url.pathname);
+ if (!match) return undefined;
+ return { role: match[1], filename: match[2], url: url.href };
+}
+
+function basicAuthorization(role, code) {
+ if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
+ throw new PortalDownloadError('session');
+ }
+ const bytes = new TextEncoder().encode(`${role}:${code}`);
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return `Basic ${btoa(binary)}`;
+}
+
+function safeFilename(value, fallback) {
+ if (typeof value !== 'string') return fallback;
+ const name = value.trim();
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(name) ? name : fallback;
+}
+
+function responseFilename(response, fallback) {
+ const disposition = response.headers.get('content-disposition') ?? '';
+ const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
+ if (encoded) {
+ try { return safeFilename(decodeURIComponent(encoded), fallback); } catch { /* Use URL filename. */ }
+ }
+ const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
+ return safeFilename(quoted, fallback);
+}
+
+export class PortalDownloadError extends Error {
+ constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
+}
+
+export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
+ if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
+ const response = await fetcher(resource.url, {
+ method: 'GET',
+ headers: { Authorization: basicAuthorization(credential.role, credential.code) },
+ credentials: 'omit',
+ cache: 'no-store',
+ redirect: 'error',
+ referrerPolicy: 'no-referrer',
+ });
+ if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
+ if (!response.ok) throw new PortalDownloadError('unavailable');
+ return { blob: await response.blob(), filename: responseFilename(response, resource.filename) };
+}
+
+export function portalDownloadErrorMessage(error) {
+ if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
+ if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
+ return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
+ }
+ return 'The document could not be downloaded. Please try again or contact AIA.';
+}
diff --git a/www/tools/service-portal-browser.mjs b/www/tools/service-portal-browser.mjs
index 34b27d4..e9066ad 100644
--- a/www/tools/service-portal-browser.mjs
+++ b/www/tools/service-portal-browser.mjs
@@ -16,6 +16,8 @@ import { setTimeout as delay } from 'node:timers/promises';
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
assert.ok(['live', 'off', 'mock'].includes(forms));
+const browserName = process.argv.find(a => a.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
+assert.ok(['chromium', 'firefox'].includes(browserName));
const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
@@ -33,7 +35,7 @@ const routes = [
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
];
const widths = [320, 390, 768, 1440];
-const evidence = { forms, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' };
+const evidence = { forms, browserName, routes, widths, checks: 0, blocked: [], jotformAttempts: [], resourceRequests: [], screenshots: [], fallbackChecks: [], status: 'failed' };
const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port;
@@ -47,9 +49,11 @@ function expectedUrl(locale) {
}
try {
- const { chromium } = await import(moduleName);
- browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
- ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
+ const drivers = await import(moduleName);
+ const driver = drivers[browserName];
+ const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
+ browser = await driver.launch(executablePath
+ ? { headless: true, executablePath }
: { headless: true });
evidence.browser = browser.version();
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
@@ -73,14 +77,31 @@ try {
const styledFixture = '';
const staffTemplates = '';
const payload = { alg: portalAlgorithm, streams: [
- await encryptPortalStream('fixture-staff', 'Synthetic Staff Staff fixture only
Synthetic resource ' + styledFixture + staffTemplates, staffCode),
- await encryptPortalStream('fixture-management', 'Synthetic Management Management fixture only
' + styledFixture, managementCode),
+ await encryptPortalStream('b', 'Synthetic Staff Staff fixture only
Download staff guide ' + styledFixture + staffTemplates, staffCode),
+ await encryptPortalStream('a', 'Synthetic Management Management fixture only
Download management guide ' + styledFixture, managementCode),
] };
+ const expectedAuthorization = {
+ staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'),
+ management: 'Basic ' + Buffer.from('management:' + managementCode, 'utf8').toString('base64'),
+ };
const guard = async (context, inject = false) => {
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
+ const resource = /^\/(staff|management)-resources\/(staff|management)-guide\.pdf$/.exec(url.pathname);
+ if (resource) {
+ const role = resource[1];
+ const authorized = req.headers().authorization === expectedAuthorization[role];
+ evidence.resourceRequests.push({ role, authorized });
+ if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } });
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/pdf',
+ headers: { 'content-disposition': `attachment; filename="${role}-guide.pdf"`, 'cache-control': 'private, no-store' },
+ body: `%PDF-1.7\nSynthetic ${role} document\n`,
+ });
+ }
if (inject && url.pathname === '/employee-portal') {
const response = await route.fetch();
const html = (await response.text()).replace(/(