feat(security): implement API-verified passcode auth with JWT session

Passcodes are no longer compared locally against cached localStorage data.
Entry now POSTs to /v3/action/auth/authenticate_passcode; on success the
returned JWT (with per-role TTL) is stored in $ae_loc.jwt. Page-load
expiry check in +layout.ts resets access_type to anonymous when the JWT
has expired, targeting only auth_type='passcode' JWTs.

- Debounce (600 ms) auto-fires the check after typing stops; Enter key
  fires immediately as a secondary trigger — preserving the original UX
- Inline spinner and error message added to the passcode input
- Silent fallback to local comparison on network error or unresolved
  site_id (ghost), so IDAA staff and Electron/Launcher contexts are safe
- USE_API_PASSCODE_AUTH = true (active); local fallback retained while
  production is observed; site_access_code_kv cleanup deferred

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-18 13:32:44 -04:00
parent e09757f2b1
commit d939f3190d
2 changed files with 168 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import { afterNavigate } from '$app/navigation';
// *** Import other supporting libraries
// import { liveQuery } from "dexie";
import {
Loader,
Lock,
LockOpen,
ShieldEllipsis,
@@ -60,6 +61,17 @@ let checked_passcode: null | string = $state(null);
// let trigger: null|string|boolean = null;
let trigger: null | string | boolean = $state(null);
// ── Secure passcode auth (API-verified) ──────────────────────────────────────
// Set USE_API_PASSCODE_AUTH = true to activate the new API-verified path.
// When false, the original local comparison runs unchanged.
// When true, the component POSTs to /api/authenticate_passcode and stores the
// returned JWT. On network or unexpected API errors it falls back to the local
// method automatically so IDAA staff and other users are never left locked out.
const USE_API_PASSCODE_AUTH = true;
let auth_pending: boolean = $state(false);
let auth_error: string | null = $state(null);
// const dispatch = createEventDispatcher();
// WARNING: There is a bug (I think) around here related to the entered_passcode not being cleared. There seems to be something different about how Svelte handles state in this component compared to the others. This might be related to the `$effect` or `$derived` usage. Maybe there are conflicting things trying to update the $ae_loc store at the same time.
@@ -96,17 +108,21 @@ onDestroy(() => {
// });
$effect(() => {
if (
entered_passcode &&
entered_passcode.length >= 5 &&
entered_passcode != checked_passcode
) {
if (!USE_API_PASSCODE_AUTH) {
// Local path: synchronous comparison, safe to auto-fire on each qualifying keystroke.
if (entered_passcode && entered_passcode.length >= 5 && entered_passcode != checked_passcode) {
checked_passcode = entered_passcode;
if (log_lvl) {
console.log(`entered_passcode=${entered_passcode}`);
if (log_lvl) console.log(`entered_passcode=${entered_passcode}`);
handle_check_passcode_local();
}
handle_check_access_type_passcode();
return;
}
// API path: debounce — fire 600ms after the user stops typing.
// The $effect cleanup (returned fn) cancels the previous timer on each new keystroke.
// Enter key bypasses the debounce via the onkeydown handler on the input.
if (!entered_passcode || entered_passcode.length < 5) return;
const timer = setTimeout(() => handle_check_access_type_passcode(), 600);
return () => clearTimeout(timer);
});
$effect(() => {
@@ -163,10 +179,113 @@ $effect(() => {
}
});
// Clear the API auth error as soon as the user changes their input after a rejection.
// Reset the local-path guard (checked_passcode) when input is fully cleared so the
// user can retype the same code after clearing the field.
$effect(() => {
if (entered_passcode) {
auth_error = null;
} else {
checked_passcode = null;
}
});
// Dispatcher — called from the Enter-key handler on the input (API path) or
// from the $effect auto-check (local path). Guards against concurrent API calls.
function handle_check_access_type_passcode() {
if (USE_API_PASSCODE_AUTH) {
if (auth_pending || !entered_passcode || entered_passcode.length < 5) return;
handle_check_passcode_api(); // async, intentionally not awaited
} else {
handle_check_passcode_local();
}
}
// ── New: API-verified passcode auth ──────────────────────────────────────────
// POSTs to /api/authenticate_passcode. On success stores the returned JWT so
// the session has a TTL and passcodes never need to be cached client-side.
// Falls back to the local method on any network or unexpected API error to
// ensure IDAA staff and onsite users are never locked out.
async function handle_check_passcode_api() {
const site_id = $ae_loc?.site_id;
if (!site_id || site_id === 'ghost' || !entered_passcode) {
// No valid site context yet — fall back to local comparison.
handle_check_passcode_local();
return;
}
auth_pending = true;
auth_error = null;
try {
const api_key =
($ae_api?.headers?.['x-aether-api-key'] as string) ??
($ae_api?.api_secret_key as string) ??
'';
const resp = await fetch(`${$ae_api.base_url}/v3/action/auth/authenticate_passcode`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-aether-api-key': api_key
},
body: JSON.stringify({ site_id, passcode: entered_passcode })
});
const json = await resp.json();
if (resp.ok && json?.data?.role) {
const role = json.data.role as string;
const jwt = json.data.jwt as string | null;
$ae_loc.access_type = role as typeof $ae_loc.access_type;
if (jwt) $ae_loc.jwt = jwt;
window.localStorage.setItem('access_type', role);
entered_passcode = '';
show_passcode_input = false;
trigger = 'process_permission_check';
$ae_loc.app_cfg.show_element__menu = false;
$ae_loc.app_cfg.show_element__menu_btn = true;
if (!$ae_loc.iframe && $ae_loc.authenticated_access) {
$ae_loc.app_cfg.show_element__access_type = true;
$ae_loc.app_cfg.show_element__cfg = true;
} else if ($ae_loc.iframe && $ae_loc.trusted_access) {
$ae_loc.app_cfg.show_element__access_type = true;
$ae_loc.app_cfg.show_element__cfg = true;
} else {
$ae_loc.app_cfg.show_element__access_type = true;
$ae_loc.app_cfg.show_element__cfg = false;
}
} else if (resp.status === 401) {
auth_error = 'Incorrect passcode.';
} else if (resp.status === 404) {
// Site not found — configuration problem; fall back silently.
console.warn('passcode auth: site not found, falling back to local');
handle_check_passcode_local();
} else {
// Unexpected response — fall back so the user isn't locked out.
console.warn('passcode auth: unexpected status', resp.status, '— falling back to local');
handle_check_passcode_local();
}
} catch (_err) {
// Network/parse error — fall back to local comparison.
console.warn('passcode auth: network error, falling back to local');
handle_check_passcode_local();
} finally {
auth_pending = false;
}
}
// ── Legacy: local passcode comparison (original method) ──────────────────────
// Kept intact so USE_API_PASSCODE_AUTH=false preserves the existing behaviour
// exactly, and so the API path can fall back here on errors.
function handle_check_passcode_local() {
if (log_lvl > 1) {
console.log(
`*** handle_check_access_type_passcode() *** passcode list:`,
`*** handle_check_passcode_local() *** passcode list:`,
$ae_loc.site_access_code_kv
);
}
@@ -508,11 +627,19 @@ function handle_clear_access() {
class:hidden={!show_passcode_input}
type="text"
placeholder="Passcode"
onkeydown={(e) => { if (e.key === 'Enter') handle_check_access_type_passcode(); }}
autofocus={show_passcode_input} />
<!-- <div class="current_text transition-all">{$ae_loc.access_type}</div> -->
{#if auth_pending}
<Loader size="1em" class="animate-spin text-gray-400" />
{/if}
</span>
{/if}
</div>
{#if auth_error}
<span class="text-xs text-red-600 dark:text-red-400">{auth_error}</span>
{/if}
</section>
<style lang="scss">

View File

@@ -84,6 +84,36 @@ export async function load({ fetch, params, parent, route, url }) {
if (ae_loc_json.jwt) {
ae_api_init['jwt'] = ae_loc_json.jwt;
}
// Enforce passcode JWT TTL on every page load.
// Decodes the JWT payload (base64, no secret needed) and resets the
// session to anonymous if the passcode JWT has expired.
// User login JWTs (auth_type !== 'passcode') are never touched here.
// This block is always active but only fires once USE_API_PASSCODE_AUTH
// has been flipped and the user has a passcode JWT in their ae_loc.
if (ae_loc_json?.jwt) {
try {
const parts = ae_loc_json.jwt.split('.');
if (parts.length === 3) {
const jwt_payload = JSON.parse(atob(parts[1]));
const json_str =
typeof jwt_payload.json_str === 'string'
? JSON.parse(jwt_payload.json_str)
: jwt_payload.json_str;
if (
json_str?.auth_type === 'passcode' &&
jwt_payload.eat < Date.now() / 1000
) {
// Passcode session has expired — drop to anonymous.
ae_loc_json.jwt = null;
ae_loc_json.access_type = 'anonymous';
localStorage.setItem('ae_loc', JSON.stringify(ae_loc_json));
}
}
} catch {
// Malformed JWT — leave untouched, let existing handling deal with it.
}
}
}
} catch (e) {
// Silently fail on storage read