fix: four review-confirmed regressions in auth + OIDC flows

All found by the multi-angle /code-review over the branch and verified
against the installed libraries:

- 401 discriminator: the session-expiry redirect keyed off a path prefix
  (!/auth/*) and falsely logged users out on /profile/change-password
  with a wrong current password. requireAuth's no-session 401 now carries
  the distinct AUTH_REQUIRED code — the only signal api.ts dispatches
  auth:expired on; wrong-credential 401s keep AUTH_ERROR on any path.
  Test updated to pin the code-based contract on both directions.
- OIDC RFC 9207: exchangeAndVerifyTokens rebuilt the callback URL with
  only code+state, dropping the iss parameter openid-client v6 validates
  when the provider advertises support (authentik does) — every OIDC
  login would fail post-upgrade. The full callback query is now forwarded.
- OIDC http issuers: v6 enforces HTTPS on discovery/endpoints (v5
  didn't) — breaking internal http:// providers on Docker networks.
  OIDC_ALLOW_INSECURE_HTTP=true opts back in (documented in .env.example).
- 2FA downgrade: authService's webauthn fallback caught ANY challenge
  error and silently downgraded to password-only login; now only the
  stale-flag 'No registered WebAuthn credentials' case falls through,
  everything else rethrows.
- Burned-challenge retry: the server consumes the single-use 2FA
  challenge before verifying (anti-replay), so after a server rejection
  a retry can only ever return 'Challenge expired'. LoginPage now resets
  to sign-in on server failure (WebAuthn AND the pre-existing TOTP trap);
  a browser-prompt cancel keeps retry (token still live). Also lazy-loads
  @simplewebauthn/browser out of the entry bundle, matching ProfilePage.

Server 252/252, client 52/52, OIDC smoke 44/44, probe 33/33, e2e 27.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-10 22:57:07 -05:00
parent a00013d778
commit 2a07e762a2
8 changed files with 86 additions and 39 deletions

View File

@ -69,6 +69,12 @@ NODE_ENV=production
# #
# WEBAUTHN_RP_ID=bills.example.com # WEBAUTHN_RP_ID=bills.example.com
# openid-client v6 requires HTTPS for the OIDC issuer and all discovered
# endpoints. If your identity provider is reached over an internal http://
# address (e.g. http://authentik:9000 on a Docker network), opt back in:
#
# OIDC_ALLOW_INSECURE_HTTP=true
# ── Bank Sync (SimpleFIN) ───────────────────────────────────────────────────── # ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own # Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required. # SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -1,22 +1,21 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// 1e (QA follow-up) — a 401 outside /auth/* means the session died mid-use: // Session-expiry contract: the auth middleware is the only source of the
// api.ts must dispatch 'auth:expired' (AuthProvider clears the user and // AUTH_REQUIRED code (no valid session) — api.ts dispatches 'auth:expired' on
// RequireAuth redirects). A 401 under /auth/* is "wrong credential input" // it so AuthProvider clears the user and RequireAuth redirects. Any other 401
// (e.g. change-password with a bad current password) and must NOT dispatch. // (code AUTH_ERROR — wrong credential input, e.g. change-password with a bad
// current password, on ANY path) must NOT dispatch. A path prefix cannot make
// that distinction — /profile/change-password proved it in review.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api } from '@/api'; import { api } from '@/api';
function mock401(): typeof fetch { function mock401(code: string): typeof fetch {
return vi.fn( return vi.fn(
async () => async () =>
new Response( new Response(JSON.stringify({ error: code, message: 'Unauthorized', code }), {
JSON.stringify({ error: 'AUTH_ERROR', message: 'Unauthorized', code: 'AUTH_ERROR' }), status: 401,
{ headers: { 'Content-Type': 'application/json' },
status: 401, }),
headers: { 'Content-Type': 'application/json' },
},
),
) as unknown as typeof fetch; ) as unknown as typeof fetch;
} }
@ -29,7 +28,6 @@ describe('session-expiry dispatch on 401', () => {
beforeEach(() => { beforeEach(() => {
expiredEvents = 0; expiredEvents = 0;
window.addEventListener('auth:expired', onExpired); window.addEventListener('auth:expired', onExpired);
vi.stubGlobal('fetch', mock401());
}); });
afterEach(() => { afterEach(() => {
@ -37,12 +35,17 @@ describe('session-expiry dispatch on 401', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('dispatches auth:expired for a 401 on a data endpoint', async () => { it('dispatches auth:expired for AUTH_REQUIRED (dead session) on a data endpoint', async () => {
vi.stubGlobal('fetch', mock401('AUTH_REQUIRED'));
await expect(api.bills()).rejects.toMatchObject({ status: 401 }); await expect(api.bills()).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(1); expect(expiredEvents).toBe(1);
}); });
it('does NOT dispatch for a 401 under /auth/* (wrong credential input)', async () => { it('does NOT dispatch for AUTH_ERROR (wrong credential) — even outside /auth/*', async () => {
vi.stubGlobal('fetch', mock401('AUTH_ERROR'));
await expect(
api.changeProfilePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 });
await expect( await expect(
api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }), api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 }); ).rejects.toMatchObject({ status: 401 });

View File

@ -147,11 +147,13 @@ async function _fetch<T = unknown>(
_csrfFetch = null; _csrfFetch = null;
return _fetch<T>(method, path, body, true); return _fetch<T>(method, path, body, true);
} }
// Session expired mid-use: a 401 outside /auth/* means the session cookie // Session expired mid-use: the auth middleware is the only source of
// died (under /auth/* a 401 is "wrong credential input" — e.g. bad current // AUTH_REQUIRED (no valid session). Wrong-credential 401s (change-password,
// password — and must NOT log the user out). AuthProvider listens and // TOTP disable, …) carry AUTH_ERROR and must NOT log the user out — a path
// clears the user, so RequireAuth redirects to /login preserving state.from. // prefix can't make that distinction (/profile/change-password proved it).
if (res.status === 401 && !path.startsWith('/auth/')) { // AuthProvider listens and clears the user; RequireAuth then redirects to
// /login preserving state.from.
if (res.status === 401 && data?.code === 'AUTH_REQUIRED') {
window.dispatchEvent(new Event('auth:expired')); window.dispatchEvent(new Event('auth:expired'));
} }
throw toApiError(res, data); throw toApiError(res, data);

View File

@ -2,7 +2,6 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react'; import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { startAuthentication } from '@simplewebauthn/browser';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils'; import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth'; import { useAuth, type User } from '@/hooks/useAuth';
@ -186,19 +185,29 @@ export default function LoginPage() {
setWebauthnError(''); setWebauthnError('');
setWebauthnLoading(true); setWebauthnLoading(true);
try { try {
// Lazy: keep the WebAuthn lib out of the login-critical entry bundle
// (only key-protected accounts ever reach this; ProfilePage does the same).
const { startAuthentication } = await import('@simplewebauthn/browser');
const response = await startAuthentication({ const response = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'], optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
}); });
const data = await api.webauthnChallenge({ challenge_token: challengeToken, response }); const data = await api.webauthnChallenge({ challenge_token: challengeToken, response });
handlePostLogin((data as { user?: User }).user!); handlePostLogin((data as { user?: User }).user!);
} catch (err) { } catch (err) {
// NotAllowedError = user cancelled / timed out at the browser prompt. // NotAllowedError = user cancelled / timed out at the browser prompt —
// the endpoint was never called, so the single-use challenge token is
// still valid and retry works. Any SERVER failure has already consumed
// the token (anti-replay), so a retry can only ever return "Challenge
// expired" — send the user back to sign in instead of a dead button.
const cancelled = (err as Error)?.name === 'NotAllowedError'; const cancelled = (err as Error)?.name === 'NotAllowedError';
setWebauthnError( if (cancelled) {
cancelled setWebauthnError('Security key prompt was cancelled. Try again.');
? 'Security key prompt was cancelled. Try again, or go back and sign in again.' } else {
: errMessage(err, 'Security key verification failed.'), setWebauthnChallenge(null);
); setWebauthnOptions(null);
setWebauthnError('');
setError(errMessage(err, 'Security key verification failed.') + ' Please sign in again.');
}
} finally { } finally {
setWebauthnLoading(false); setWebauthnLoading(false);
} }
@ -217,8 +226,14 @@ export default function LoginPage() {
const data = await api.totpChallenge(payload); const data = await api.totpChallenge(payload);
handlePostLogin(data.user!); handlePostLogin(data.user!);
} catch (err) { } catch (err) {
setTotpError(errMessage(err, 'Invalid code.')); // The server consumes the single-use challenge token BEFORE verifying the
// code (anti-replay), so after any server rejection a retry with the same
// token can only return "Challenge expired" — restart the sign-in instead
// of leaving a form that can never succeed. (Same semantics as WebAuthn.)
setTotpChallenge(null);
setTotpCode(''); setTotpCode('');
setTotpError('');
setError(errMessage(err, 'Invalid code.') + ' Please sign in again.');
} finally { } finally {
setTotpLoading(false); setTotpLoading(false);
} }

View File

@ -64,7 +64,11 @@ function requireAuth(req: Req, res: Res, next: Next): any {
} }
const user = getSessionUser(req.cookies?.[COOKIE_NAME]); const user = getSessionUser(req.cookies?.[COOKIE_NAME]);
if (!user) return res.status(401).json(standardizeError('Not authenticated', 'AUTH_ERROR')); // AUTH_REQUIRED (not AUTH_ERROR): the one code that means "no valid session".
// The client keys its session-expired redirect off this code — wrong-credential
// 401s elsewhere (change-password, TOTP disable, …) use AUTH_ERROR and must
// NOT log the user out.
if (!user) return res.status(401).json(standardizeError('Not authenticated', 'AUTH_REQUIRED'));
req.user = user; req.user = user;
next(); next();
} }

View File

@ -94,10 +94,12 @@ router.get('/callback', async (req: Req, res: Res) => {
} }
try { try {
// Full token exchange + cryptographic ID token verification via openid-client@5. // Full token exchange + cryptographic ID token verification via openid-client@6.
// Verifies: state, PKCE, JWT signature (JWKS), issuer, audience, expiry, nonce. // Verifies: state, PKCE, JWT signature (JWKS), issuer, audience, expiry, nonce,
// Throws on any validation failure. Tokens are never logged. // and the RFC 9207 `iss` response parameter — which is why the FULL callback
const claims = await exchangeAndVerifyTokens(config, code, stateId, savedState); // query is forwarded, not just code/state. Throws on any validation failure.
// Tokens are never logged.
const claims = await exchangeAndVerifyTokens(config, code, stateId, savedState, req.query);
// Map verified claims to a local user account // Map verified claims to a local user account
const user = await findOrProvisionUser(claims, config); const user = await findOrProvisionUser(claims, config);

View File

@ -94,10 +94,14 @@ async function login(username: any, password: any) {
const loginToken = createLoginChallenge(getDb(), user.id, challengeId); const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options }; return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
} catch (err: any) { } catch (err: any) {
// Stale flag (enabled but no usable credentials): a hard failure here would // ONLY the stale-flag case (enabled but no usable credentials) may fall
// lock the account out entirely — fall through to password-only login instead. // through to password-only login — a hard failure there would lock the
// account out entirely. Any other error (DB/crypto hiccup) rethrows:
// silently downgrading 2FA to password-only on a transient fault would
// let an attacker with the password ride out the second factor.
if (err.message !== 'No registered WebAuthn credentials') throw err;
log.warn( log.warn(
`[auth] WebAuthn enabled for user ${user.id} but challenge creation failed (${err.message}); proceeding with password login`, `[auth] WebAuthn enabled for user ${user.id} but no usable credentials; proceeding with password login`,
); );
} }
} }

View File

@ -493,11 +493,17 @@ async function getOidcClient(config) {
config.tokenEndpointAuthMethod === 'client_secret_post' config.tokenEndpointAuthMethod === 'client_secret_post'
? oidc.ClientSecretPost(config.clientSecret) ? oidc.ClientSecretPost(config.clientSecret)
: oidc.ClientSecretBasic(config.clientSecret); : oidc.ClientSecretBasic(config.clientSecret);
// openid-client v6 enforces HTTPS on the issuer and every discovered
// endpoint (v5 allowed http). Self-hosters commonly reach the provider over
// an internal http address (e.g. http://authentik:9000 on a Docker network),
// so OIDC_ALLOW_INSECURE_HTTP=true opts back in — deliberate and explicit.
const allowHttp = process.env.OIDC_ALLOW_INSECURE_HTTP === 'true';
_cachedClient = await oidc.discovery( _cachedClient = await oidc.discovery(
new URL(config.issuerUrl), new URL(config.issuerUrl),
config.clientId, config.clientId,
{ redirect_uris: [config.redirectUri], response_types: ['code'] }, { redirect_uris: [config.redirectUri], response_types: ['code'] },
clientAuth, clientAuth,
allowHttp ? { execute: [oidc.allowInsecureRequests] } : undefined,
); );
_cachedClientKey = clientKey; _cachedClientKey = clientKey;
_cacheTs = now; _cacheTs = now;
@ -649,14 +655,19 @@ async function buildAuthorizationUrl(config, state) {
* *
* Tokens are never logged. Returns verified claims on success. * Tokens are never logged. Returns verified claims on success.
*/ */
async function exchangeAndVerifyTokens(config, code, stateId, savedState) { async function exchangeAndVerifyTokens(config, code, stateId, savedState, callbackQuery = {}) {
const client = await getOidcClient(config); const client = await getOidcClient(config);
// authorizationCodeGrant() handles token exchange + full validation in one // authorizationCodeGrant() handles token exchange + full validation in one
// step (signature via discovered JWKS, iss/aud/exp/nbf, PKCE, state, nonce). // step (signature via discovered JWKS, iss/aud/exp/nbf, PKCE, state, nonce).
// It throws on any problem. The "current URL" is the callback URL as the // It throws on any problem. The "current URL" must carry EVERY parameter the
// provider redirected to it. // provider actually returned — v6 validates the RFC 9207 `iss` parameter when
// the provider advertises authorization_response_iss_parameter_supported
// (authentik does), so rebuilding with only code/state breaks the exchange.
const currentUrl = new URL(config.redirectUri); const currentUrl = new URL(config.redirectUri);
for (const [key, value] of Object.entries(callbackQuery)) {
if (typeof value === 'string') currentUrl.searchParams.set(key, value);
}
currentUrl.searchParams.set('code', code); currentUrl.searchParams.set('code', code);
currentUrl.searchParams.set('state', stateId); currentUrl.searchParams.set('state', stateId);