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:
@@ -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
|
||||
) {
|
||||
checked_passcode = entered_passcode;
|
||||
if (log_lvl) {
|
||||
console.log(`entered_passcode=${entered_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}`);
|
||||
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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user