feat(auth): persist verified access key to allow keyless internal navigation

Sites requiring a ?key= param (e.g. IDAA Novi iframe pages) no longer need
the key appended to every internal link after the first successful verification.
Stored key is always validated against the current site config from the API —
stale or rotated keys are denied immediately. Key present in URL always takes
the strict live-validation path with no cache shortcut.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-01 09:33:21 -04:00
parent 8fabaf28f7
commit 63ec7f4cc2

View File

@@ -400,14 +400,38 @@ export async function load({ fetch, params, parent, route, url }) {
ae_loc_init['base_url'] = url.origin;
ae_loc_init['hostname'] = url.hostname;
// --- Access key verification ---
// Sites without a configured key (most non-IDAA clients) always pass — no key required.
// Sites with a key (e.g. IDAA Novi iframe pages) require verification on first visit.
//
// There are three cases:
//
// CASE 1 — Site requires no key: immediate access, no localStorage needed.
//
// CASE 2 — Key present in URL (?key=...): always live-validated against the API response.
// - Match → allow_access = key value (truthy), key_checked = key value.
// - No match → allow_access = false. Access denied immediately.
// - This path is ALWAYS strict. There is no shortcut through cache.
// - The key only needs to be in the iframe src URL on the first page load (or after
// key rotation). Internal links (recovery meeting detail, BB posts, archive content)
// do NOT need to carry the key as a param once the user is in.
//
// CASE 3 — No key in URL: check localStorage for a previously verified key.
// - The stored key is compared against the CURRENT site config from the API —
// so if the key was rotated, the stored value will not match and access is denied
// until the user presents the new key via URL param again.
// - allow_access remains falsy if no stored key or stored key doesn't match.
// - Sites that never require a key (Case 1) never reach this branch.
if (
!ae_loc_init['site_access_key'] &&
!ae_loc_init['site_domain_access_key']
) {
// CASE 1: No key required for this site.
ae_loc_init['key_checked'] = true;
ae_loc_init['allow_access'] = true;
} else {
if (access_key) {
// CASE 2: Key present in URL — always live-validate, no cache shortcut.
if (log_lvl) {
console.log(`root +layout.ts: access_key = ${access_key}`);
}
@@ -420,11 +444,34 @@ export async function load({ fetch, params, parent, route, url }) {
ae_loc_init['allow_access'] =
ae_loc_init['site_domain_access_key'];
} else {
// Key present but wrong — deny immediately.
ae_loc_init['key_checked'] = true;
ae_loc_init['allow_access'] = false;
}
} else {
// CASE 3: No key in URL — check localStorage for a prior verified key.
// Allows internal navigation (recovery meeting detail pages, BB posts, archive
// content, etc.) without appending ?key= to every internal link.
// Security contract: stored key is validated against the CURRENT site config
// from the API response — not blindly trusted from localStorage. If the key
// was rotated, the stored value won't match and access will be denied.
ae_loc_init['key_checked'] = true;
try {
const ae_loc_raw = localStorage.getItem('ae_loc');
if (ae_loc_raw) {
const stored = JSON.parse(ae_loc_raw);
const stored_key = stored.key_checked;
if (
stored_key &&
(stored_key === ae_loc_init['site_access_key'] ||
stored_key === ae_loc_init['site_domain_access_key'])
) {
ae_loc_init['allow_access'] = stored_key;
}
}
} catch {
// Silently fail on storage read
}
}
}