pediatric-ai-scribe-v3/public/js/authFetch.js
Daniel 22683f3584
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: sign in with a code emailed to you, offered beside the password
The sign-in screen asks for an email first, then offers both ways in together:
a six-digit code sent to that address, or the password. Beside rather than
instead — a code depends on mail being delivered and a password does not, so
neither may be the only route. "Use a different email" goes back a step, and
creating an account stays where it was.

What keeps it from being a second, weaker front door:

- Only a bcrypt hash is stored, so a code read out of the database is not a
  working credential.
- Ten minutes, single use, marked used before the session is issued so a replay
  cannot race it, and requesting a new one deletes the old.
- Five wrong guesses burn it. Six digits is a million possibilities, which is
  plenty against a person and nothing against a script with unlimited tries.
- Requesting a code answers identically whether or not the address exists, and
  every verify failure returns one message. A sign-in screen that says "no such
  account" is a way of finding out who has one.
- Two-factor still applies: a code proves you can read the mailbox, which is one
  factor, and an account that asked for a second still wants it.
- Its own rate limits, tighter for requesting than for attempting, because
  requesting sends mail to someone else's address. These had to be separate
  limiters: Express matches app.use paths on segment boundaries, so
  /api/auth/login does not cover /api/auth/login-code — checked against a real
  router rather than assumed.

Two bugs found while building it, both mine:

authFetch keeps an allowlist of endpoints callable with no verified owner and
rejects everything else before it is sent. The new endpoints were not on it, so
the request never left the browser and surfaced as "Connection error".

reveal() hid elements by appending 'hidden' to className and showed them with a
non-global replace, so hiding twice left two copies and showing stripped one.
The "use a different email" link never reappeared. It uses classList now, which
is idempotent.

Verified against the running server: correct code signs in, the same code again
is refused, a superseded code is refused, five wrong guesses burn it, an expired
one is refused, and the stored value is a hash. In the browser: requesting a
code advances the screen, a wrong code is refused without losing the screen, and
the password route still signs in.

Not yet demonstrated: a correct code typed into the browser. The harness keeps
racing the one-live-code rule — the page's own request supersedes whatever code
the test holds, and with SMTP off the delivered one cannot be read. The same
request reaches the server on the wrong-code path, and the endpoint itself is
verified, but that last step is untested end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:12:03 +02:00

96 lines
4.8 KiB
JavaScript

// Account-bound requests cannot outlive the document's verified owner.
if (!window.__fetchAuthIntercepted) {
window.__fetchAuthIntercepted = true;
var rawFetch = window.fetch.bind(window);
var boundary = window.AccountBoundary;
// Callable with no verified owner, because they are how one is obtained.
// Anything not on this list is rejected before it is sent, which is right —
// but it means a new sign-in endpoint has to be added here or it fails as a
// "Connection error" with no request ever leaving the browser.
var authPaths = new Set([
'/api/auth/login', '/api/auth/register', '/api/auth/logout',
'/api/auth/login-code/request', '/api/auth/login-code/verify',
'/api/auth/forgot-password', '/api/auth/reset-password',
'/api/auth/verify-email', '/api/auth/resend-verification',
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
]);
boundary.logoutRequest = function(headers) {
return rawFetch('/api/auth/logout', {
method: 'POST', headers: headers, credentials: 'same-origin', keepalive: true
});
};
boundary.cookieSessionRequest = function() {
return rawFetch('/api/auth/me', { credentials: 'same-origin', cache: 'no-store' });
};
if (navigator.sendBeacon) {
var sendBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(input, data) {
var url = new URL(input, window.location.href);
if (url.origin === window.location.origin && url.pathname.startsWith('/api/') && !boundary.active()) return false;
return sendBeacon(input, data);
};
}
window.fetch = function(input, init) {
var url;
try { url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url, window.location.href); }
catch (e) { return rawFetch(input, init); }
if (url.origin !== window.location.origin || !url.pathname.startsWith('/api/')) return rawFetch(input, init);
if (boundary.blocked()) return Promise.reject(boundary.error());
var ticket = boundary.capture(); // Also detects shared-cookie changes before storage events arrive.
if (boundary.blocked()) return Promise.reject(boundary.error());
// No clinical module may preload a previous cookie's data on the login screen.
if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') {
return Promise.reject(boundary.error());
}
// Signing in with a code establishes a session exactly as a password does,
// so the boundary has to be prepared for a new owner the same way.
var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register'
|| url.pathname === '/api/auth/login-code/verify')
&& String((init && init.method) || (input && input.method) || 'GET').toUpperCase() === 'POST';
try { if (login) boundary.startLogin(); } catch (e) { return Promise.reject(e); }
var revision = boundary.revision();
var shared = boundary.read();
var generation = shared && shared.generation;
var abort = new AbortController();
var callerSignal = (init && init.signal) || (input && input.signal);
var accountSignal = boundary.signal();
function cancel() { abort.abort(); }
if (callerSignal) {
if (callerSignal.aborted) cancel();
else callerSignal.addEventListener('abort', cancel, { once: true });
}
if (!login) accountSignal.addEventListener('abort', cancel, { once: true });
function check() {
var now = boundary.read();
if (revision !== boundary.revision() || generation !== (now && now.generation)
|| (!login && (boundary.blocked() || (ticket && !boundary.valid(ticket))))) throw boundary.error();
}
function guardResponse(resp) {
check();
if (resp.status === 401 && ticket && !authPaths.has(url.pathname)) {
boundary.end();
boundary.reload();
throw boundary.error();
}
// Body parsing can finish after fetch itself; gate those continuations too.
['json', 'text', 'blob', 'arrayBuffer', 'formData'].forEach(function(method) {
if (!resp[method]) return;
var original = resp[method].bind(resp);
resp[method] = function() { check(); return original().then(function(value) { check(); return value; }); };
});
if (resp.clone) {
var clone = resp.clone.bind(resp);
resp.clone = function() { check(); return guardResponse(clone()); };
}
return resp;
}
return rawFetch(input, Object.assign({}, init, { signal: abort.signal }))
.then(guardResponse).finally(function() {
if (callerSignal) callerSignal.removeEventListener('abort', cancel);
// Keep the account abort listener until document disposal: it also
// cancels a response body that has not finished streaming yet.
});
};
}