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

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