@micha.bigler/ui-core-micha 2.4.3 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // src/auth/AuthContext.jsx
3
3
  import React, { createContext, useState, useEffect, } from 'react';
4
4
  import { ensureCsrfToken } from './apiClient'; // <--- IMPORT ADDED
5
5
  import { fetchAuthMethods, fetchCurrentUser, logoutSession, } from './authApi';
6
+ import { ReauthModal } from './ReauthModal';
6
7
  export const AuthContext = createContext(null);
7
8
  const DEFAULT_AUTH_METHODS = {
8
9
  password_login: true,
@@ -80,11 +81,11 @@ export const AuthProvider = ({ children }) => {
80
81
  setUser(null);
81
82
  }
82
83
  };
83
- return (_jsx(AuthContext.Provider, { value: {
84
+ return (_jsxs(AuthContext.Provider, { value: {
84
85
  user,
85
86
  authMethods,
86
87
  loading,
87
88
  login,
88
89
  logout,
89
- }, children: children }));
90
+ }, children: [children, _jsx(ReauthModal, {})] }));
90
91
  };
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useState } from 'react';
3
+ import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import apiClient from './apiClient';
6
+ import { HEADLESS_BASE } from './authConfig.jsx';
7
+ import { rejectReauth, resolveReauth, subscribe } from './reauth';
8
+ export function ReauthModal() {
9
+ const { t } = useTranslation();
10
+ const [open, setOpen] = useState(false);
11
+ const [password, setPassword] = useState('');
12
+ const [loading, setLoading] = useState(false);
13
+ const [error, setError] = useState(null);
14
+ useEffect(() => {
15
+ return subscribe((active) => {
16
+ setOpen(active);
17
+ if (!active) {
18
+ setPassword('');
19
+ setError(null);
20
+ setLoading(false);
21
+ }
22
+ });
23
+ }, []);
24
+ const handleSubmit = async (e) => {
25
+ e.preventDefault();
26
+ setLoading(true);
27
+ setError(null);
28
+ try {
29
+ await apiClient.post(`${HEADLESS_BASE}/auth/reauthenticate`, { password });
30
+ resolveReauth();
31
+ }
32
+ catch (_a) {
33
+ setError(t('Auth.REAUTH_FAILED'));
34
+ setLoading(false);
35
+ }
36
+ };
37
+ const handleCancel = () => {
38
+ rejectReauth(new Error('cancelled'));
39
+ };
40
+ return (_jsx(Dialog, { open: open, onClose: handleCancel, maxWidth: "xs", fullWidth: true, children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(DialogTitle, { children: t('Auth.REAUTH_TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), _jsx(TextField, { autoFocus: true, fullWidth: true, type: "password", label: t('Auth.LOGIN_PASSWORD_LABEL'), value: password, onChange: (e) => setPassword(e.target.value), disabled: loading, sx: { mt: 1 } })] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: handleCancel, disabled: loading, children: t('Auth.MFA_TOTP_CANCEL_BUTTON') }), _jsx(Button, { type: "submit", variant: "contained", disabled: loading || !password, children: loading ? t('Auth.MFA_TOTP_VERIFY_BUTTON_LOADING') : t('Auth.MFA_VERIFY') })] })] }) }));
41
+ }
@@ -1,5 +1,6 @@
1
1
  import axios from "axios";
2
2
  import { CSRF_URL } from "./authConfig";
3
+ import { requestReauth } from "./reauth";
3
4
  const apiClient = axios.create({
4
5
  withCredentials: true,
5
6
  xsrfCookieName: "csrftoken",
@@ -109,18 +110,34 @@ function extractAuthSignal(data) {
109
110
  }
110
111
  return { code: null, i18nKey: null };
111
112
  }
112
- apiClient.interceptors.response.use((response) => response, (error) => {
113
- var _a, _b, _c, _d, _e;
113
+ apiClient.interceptors.response.use((response) => response, async (error) => {
114
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
114
115
  const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : null;
115
116
  const data = (_d = (_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.data) !== null && _d !== void 0 ? _d : {};
116
117
  const { code, i18nKey } = extractAuthSignal(data);
117
118
  const isAuthStatus = status === 401 || status === 403;
118
119
  const isNotAuthenticated = code === "not_authenticated" || i18nKey === "auth.not_authenticated";
120
+ // Reauthentication gate: allauth returns 401 + flows[{id:"reauthenticate"}]
121
+ // when the session is stale before a sensitive operation (e.g. adding a passkey).
122
+ // Show the password-confirm modal, wait for it to complete, then retry once.
123
+ const flows = (_g = (_f = (_e = data === null || data === void 0 ? void 0 : data.data) === null || _e === void 0 ? void 0 : _e.flows) !== null && _f !== void 0 ? _f : data === null || data === void 0 ? void 0 : data.flows) !== null && _g !== void 0 ? _g : [];
124
+ const needsReauth = status === 401 &&
125
+ Array.isArray(flows) &&
126
+ flows.some((f) => f.id === "reauthenticate");
127
+ if (needsReauth && !((_h = error.config) === null || _h === void 0 ? void 0 : _h._reauthRetried)) {
128
+ try {
129
+ await requestReauth();
130
+ return apiClient(Object.assign(Object.assign({}, error.config), { _reauthRetried: true }));
131
+ }
132
+ catch (_k) {
133
+ return Promise.reject(error);
134
+ }
135
+ }
119
136
  // Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
120
137
  // expect to handle 401 silently and must not trigger a redirect-on-mount.
121
138
  // Carried as an axios config property, so it never travels to the backend
122
139
  // (would otherwise trigger a CORS preflight on cross-origin requests).
123
- const skipRedirect = ((_e = error === null || error === void 0 ? void 0 : error.config) === null || _e === void 0 ? void 0 : _e.skipAuthRedirect) === true;
140
+ const skipRedirect = ((_j = error === null || error === void 0 ? void 0 : error.config) === null || _j === void 0 ? void 0 : _j.skipAuthRedirect) === true;
124
141
  if (isAuthStatus && isNotAuthenticated && !skipRedirect) {
125
142
  redirectToLoginOnce();
126
143
  }
@@ -410,6 +410,32 @@ export async function rejectRecoveryRequest(id, supportNote) {
410
410
  });
411
411
  return res.data;
412
412
  }
413
+ // S164: server-side session handoff. After the email-link redirect lands the
414
+ // SPA on `/login#recovery=ok`, the LoginPage fetches the plaintext recovery
415
+ // token from the user's server session (set by `recovery_complete_view`).
416
+ // The endpoint pops the token on every read — a single shot per browser
417
+ // navigation — so a navigated-away tab cannot reuse the entry.
418
+ export async function fetchRecoverySessionToken() {
419
+ var _a, _b;
420
+ try {
421
+ const res = await apiClient.get('/api/auth/recovery/session-token/', {
422
+ // Visiting `/login` without a pending recovery session is the common
423
+ // case; the 404 it returns must not bubble up as a global "session
424
+ // expired" auth error in the app shell.
425
+ skipAuthRedirect: true,
426
+ });
427
+ return ((_a = res === null || res === void 0 ? void 0 : res.data) === null || _a === void 0 ? void 0 : _a.token) || null;
428
+ }
429
+ catch (err) {
430
+ if (((_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.status) === 404) {
431
+ // No pending recovery in this session — treat as a non-error so the
432
+ // LoginPage can decide whether to surface a "link expired" message
433
+ // based on the `#recovery=` hash sentinel it already inspects.
434
+ return null;
435
+ }
436
+ throw normaliseApiError(err, 'Auth.RECOVERY_TOKEN_INVALID');
437
+ }
438
+ }
413
439
  export async function loginWithRecoveryPassword(email, password, token) {
414
440
  try {
415
441
  // S50 (django-core-micha >=2.13.1): token wird im POST-Body übergeben,
@@ -0,0 +1,45 @@
1
+ // Promise broker for the reauthentication gate.
2
+ //
3
+ // When the response interceptor encounters a 401+reauthenticate, it calls
4
+ // requestReauth() and awaits the returned promise. The ReauthModal resolves
5
+ // or rejects that promise after the user submits (or cancels) the password
6
+ // dialog. Concurrent 401s share the same pending promise so only one modal
7
+ // ever appears.
8
+ let _pendingResolve = null;
9
+ let _pendingReject = null;
10
+ let _reauthPromise = null;
11
+ let _listeners = [];
12
+ export function requestReauth() {
13
+ if (_reauthPromise)
14
+ return _reauthPromise;
15
+ _reauthPromise = new Promise((resolve, reject) => {
16
+ _pendingResolve = resolve;
17
+ _pendingReject = reject;
18
+ _listeners.forEach(fn => fn(true));
19
+ });
20
+ return _reauthPromise;
21
+ }
22
+ export function resolveReauth() {
23
+ const resolve = _pendingResolve;
24
+ _pendingResolve = null;
25
+ _pendingReject = null;
26
+ _reauthPromise = null;
27
+ _listeners.forEach(fn => fn(false));
28
+ if (resolve)
29
+ resolve();
30
+ }
31
+ export function rejectReauth(error) {
32
+ const reject = _pendingReject;
33
+ _pendingResolve = null;
34
+ _pendingReject = null;
35
+ _reauthPromise = null;
36
+ _listeners.forEach(fn => fn(false));
37
+ if (reject)
38
+ reject(error || new Error('Reauthentication cancelled'));
39
+ }
40
+ export function subscribe(fn) {
41
+ _listeners = [..._listeners, fn];
42
+ return () => {
43
+ _listeners = _listeners.filter(l => l !== fn);
44
+ };
45
+ }
@@ -6,6 +6,36 @@ export const authTranslations = {
6
6
  "en": "Email address or password is incorrect.",
7
7
  "sw": "Barua pepe au nenosiri sio sahihi."
8
8
  },
9
+ "Auth.RECOVERY_TOKEN_INVALID": {
10
+ "de": "Der Recovery-Link ist ungültig oder wurde bereits verwendet.",
11
+ "fr": "Le lien de récupération est invalide ou a déjà été utilisé.",
12
+ "en": "The recovery link is invalid or has already been used.",
13
+ "sw": "Kiungo cha urejesho si halali au tayari kimetumika."
14
+ },
15
+ "Auth.RECOVERY_TOKEN_EXPIRED": {
16
+ "de": "Der Recovery-Link ist abgelaufen. Bitte fordere einen neuen an.",
17
+ "fr": "Le lien de récupération a expiré. Veuillez en demander un nouveau.",
18
+ "en": "The recovery link has expired. Please request a new one.",
19
+ "sw": "Kiungo cha urejesho kimeisha muda wake. Tafadhali omba kipya."
20
+ },
21
+ "Auth.RECOVERY_LINK_VALIDATING": {
22
+ "de": "Recovery-Link wird geprüft…",
23
+ "fr": "Vérification du lien de récupération…",
24
+ "en": "Validating recovery link…",
25
+ "sw": "Inathibitisha kiungo cha urejesho..."
26
+ },
27
+ "Auth.RECOVERY_LOGIN_WARNING": {
28
+ "de": "Recovery-Link validiert. Bitte gib dein Passwort ein.",
29
+ "fr": "Lien de récupération validé. Veuillez saisir votre mot de passe.",
30
+ "en": "Recovery link validated. Please enter your password.",
31
+ "sw": "Kiungo cha urejesho kimethibitishwa. Tafadhali weka nenosiri lako."
32
+ },
33
+ "Auth.TWO_FACTOR_REQUIRED_HINT": {
34
+ "de": "Diese App benötigt zwei Authentifizierungsfaktoren für vollen Zugriff.",
35
+ "fr": "Cette application requiert deux facteurs d’authentification pour un accès complet.",
36
+ "en": "This app requires two authentication factors for full access.",
37
+ "sw": "Programu hii inahitaji vipengele viwili vya uthibitisho kwa ufikiaji kamili."
38
+ },
9
39
  "Auth.PASSKEY_FAILED": {
10
40
  "de": "Die Anmeldung mit Passkey ist fehlgeschlagen.",
11
41
  "fr": "La connexion avec passkey a échoué.",
@@ -1757,5 +1787,23 @@ export const authTranslations = {
1757
1787
  "fr": "Politique de code d'accès enregistrée.",
1758
1788
  "en": "Access code policy saved.",
1759
1789
  "sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
1790
+ },
1791
+ "Auth.REAUTH_TITLE": {
1792
+ "de": "Identität bestätigen",
1793
+ "fr": "Confirmer votre identité",
1794
+ "en": "Confirm your identity",
1795
+ "sw": "Thibitisha utambulisho wako"
1796
+ },
1797
+ "Auth.REAUTH_SUBTITLE": {
1798
+ "de": "Bitte gib dein Passwort ein, um fortzufahren.",
1799
+ "fr": "Veuillez saisir votre mot de passe pour continuer.",
1800
+ "en": "Please enter your password to continue.",
1801
+ "sw": "Tafadhali ingiza nenosiri lako ili kuendelea."
1802
+ },
1803
+ "Auth.REAUTH_FAILED": {
1804
+ "de": "Das Passwort ist falsch.",
1805
+ "fr": "Le mot de passe est incorrect.",
1806
+ "en": "The password is incorrect.",
1807
+ "sw": "Nenosiri si sahihi."
1760
1808
  }
1761
1809
  };
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
8
8
  import { NarrowPage } from '../layout/PageLayout';
9
9
  import { AuthContext } from '../auth/AuthContext';
10
10
  // API & Services (Clean Architecture)
11
- import { loginWithRecoveryPassword, loginWithPassword } from '../auth/authApi';
11
+ import { fetchRecoverySessionToken, loginWithPassword, loginWithRecoveryPassword, } from '../auth/authApi';
12
12
  import { loginWithPasskey, startSocialLogin } from '../utils/authService';
13
13
  // Components
14
14
  import { LoginForm } from '../components/LoginForm';
@@ -23,13 +23,18 @@ export function LoginPage() {
23
23
  const [submitting, setSubmitting] = useState(false);
24
24
  const [errorKey, setErrorKey] = useState(null);
25
25
  const [mfaState, setMfaState] = useState(null); // { availableTypes: [...], identifier }
26
+ // S164: token never travels through the URL/fragment anymore. It is held
27
+ // in component state only, fetched from the server-side session handoff
28
+ // endpoint when the redirect lands the SPA here with `#recovery=ok`.
29
+ const [recoveryToken, setRecoveryToken] = useState(null);
30
+ const [recoveryFetching, setRecoveryFetching] = useState(false);
26
31
  // URL Params parsing
27
32
  const params = new URLSearchParams(location.search);
28
33
  const hashParams = new URLSearchParams(String(location.hash || "").startsWith("#") ? String(location.hash).slice(1) : String(location.hash || ""));
29
- const recoveryTokenRaw = hashParams.get('recovery') || params.get('recovery');
30
- const recoveryToken = ['invalid', 'expired'].includes(String(recoveryTokenRaw || '').toLowerCase())
31
- ? null
32
- : recoveryTokenRaw;
34
+ // After S164 the hash carries only a status sentinel: `ok`, `invalid`, or
35
+ // `expired`. We also accept the same key from query params as a defensive
36
+ // fallback for proxies that strip fragments.
37
+ const recoveryStatus = String(hashParams.get('recovery') || params.get('recovery') || '').toLowerCase();
33
38
  // Backward-compatible fallback for legacy links using query parameters.
34
39
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
35
40
  const requestedNext = params.get('next');
@@ -42,8 +47,29 @@ export function LoginPage() {
42
47
  if (requiresExtra) {
43
48
  return '/account?tab=security&from=weak_login';
44
49
  }
45
- if (requestedNext && requestedNext.startsWith('/')) {
46
- return requestedNext;
50
+ // Same-origin check via URL parser — `startsWith('/')` alone allows
51
+ // bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
52
+ // for special schemes. Resolve against the current origin and only accept
53
+ // results whose origin matches.
54
+ // Require leading-slash absolute path on the raw input (rejects relative
55
+ // inputs like `account/settings` and protocol-relative `//host` before
56
+ // they reach the URL parser, which would normalise both into a same-
57
+ // origin pathname starting with `/`).
58
+ if (requestedNext &&
59
+ typeof requestedNext === 'string' &&
60
+ requestedNext.startsWith('/') &&
61
+ !requestedNext.startsWith('//')) {
62
+ try {
63
+ const parsed = new URL(requestedNext, window.location.origin);
64
+ // Catches WHATWG bypasses like `/\evil.com` that resolve to a
65
+ // different origin even though the raw input started with `/`.
66
+ if (parsed.origin === window.location.origin) {
67
+ return parsed.pathname + parsed.search + parsed.hash;
68
+ }
69
+ }
70
+ catch (_b) {
71
+ // fall through to default redirect
72
+ }
47
73
  }
48
74
  return '/';
49
75
  };
@@ -53,6 +79,78 @@ export function LoginPage() {
53
79
  setErrorKey('Auth.SOCIAL_LOGIN_FAILED');
54
80
  }
55
81
  }, [location.search]);
82
+ // S164 handoff: on `#recovery=ok`, pull the plaintext token out of the
83
+ // server-side session (one-shot). On `invalid`/`expired`, or on any
84
+ // other non-empty value (legacy pre-S164 email links that carried the
85
+ // token directly in the hash), surface an invalid-link error so the
86
+ // user gets feedback instead of a silent login screen.
87
+ useEffect(() => {
88
+ var _a;
89
+ if (!recoveryStatus) {
90
+ return;
91
+ }
92
+ // Strip the `recovery=…` sentinel from BOTH hash and query string so a
93
+ // bookmark or page refresh does not re-trigger this effect against an
94
+ // already-popped session entry (which would render a confusing
95
+ // "invalid" error to a user who is mid-login).
96
+ //
97
+ // We use `history.replaceState` rather than `navigate(..., {replace})`
98
+ // intentionally: react-router's `navigate` would trigger an immediate
99
+ // re-render with an updated `useLocation()`, which would cause this
100
+ // effect's cleanup to set `cancelled=true` and discard the in-flight
101
+ // recovery-token fetch result. `replaceState` only mutates the browser
102
+ // URL bar; `useLocation()` stays at `recovery=ok` for this render, the
103
+ // fetch resolves, and `recoveryToken` gets set normally. The trade-off
104
+ // is that `useLocation().hash` is stale for the page lifetime — fine
105
+ // because nothing else in this component reads it.
106
+ if (typeof window !== 'undefined' && ((_a = window.history) === null || _a === void 0 ? void 0 : _a.replaceState)) {
107
+ const cleanParams = new URLSearchParams(window.location.search);
108
+ cleanParams.delete('recovery');
109
+ const cleanSearch = cleanParams.toString();
110
+ const cleanUrl = window.location.pathname + (cleanSearch ? `?${cleanSearch}` : '');
111
+ window.history.replaceState(null, '', cleanUrl);
112
+ }
113
+ if (recoveryStatus === 'expired') {
114
+ setErrorKey('Auth.RECOVERY_TOKEN_EXPIRED');
115
+ setRecoveryToken(null);
116
+ return;
117
+ }
118
+ if (recoveryStatus !== 'ok') {
119
+ // Catches `invalid` and any unknown value (including legacy email
120
+ // links that carried the plaintext token before S164).
121
+ setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
122
+ setRecoveryToken(null);
123
+ return;
124
+ }
125
+ let cancelled = false;
126
+ setRecoveryFetching(true);
127
+ fetchRecoverySessionToken()
128
+ .then((token) => {
129
+ if (cancelled)
130
+ return;
131
+ if (!token) {
132
+ // Session entry missing or already consumed — treat as invalid.
133
+ setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
134
+ setRecoveryToken(null);
135
+ return;
136
+ }
137
+ setRecoveryToken(token);
138
+ })
139
+ .catch((err) => {
140
+ if (cancelled)
141
+ return;
142
+ setErrorKey((err === null || err === void 0 ? void 0 : err.code) || 'Auth.RECOVERY_TOKEN_INVALID');
143
+ setRecoveryToken(null);
144
+ })
145
+ .finally(() => {
146
+ if (!cancelled) {
147
+ setRecoveryFetching(false);
148
+ }
149
+ });
150
+ return () => {
151
+ cancelled = true;
152
+ };
153
+ }, [recoveryStatus]);
56
154
  useEffect(() => {
57
155
  if (loading || !user)
58
156
  return;
@@ -146,5 +244,8 @@ export function LoginPage() {
146
244
  const twoFactorRequired = Boolean(authMethods === null || authMethods === void 0 ? void 0 : authMethods.two_factor_required)
147
245
  || Number((authMethods === null || authMethods === void 0 ? void 0 : authMethods.required_auth_factor_count) || 1) >= 2;
148
246
  // --- Render ---
149
- return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), twoFactorRequired && !recoveryToken && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null, disabled: submitting, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
247
+ return (_jsxs(NarrowPage, { title: t('Auth.PAGE_LOGIN_TITLE'), subtitle: t('Auth.PAGE_LOGIN_SUBTITLE'), children: [_jsx(Helmet, { children: _jsxs("title", { children: [t('App.NAME'), " \u2013 ", t('Auth.PAGE_LOGIN_TITLE')] }) }), errorKey && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(errorKey) })), recoveryFetching && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LINK_VALIDATING', 'Validating recovery link…') })), recoveryToken && !errorKey && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.') })), twoFactorRequired && !recoveryToken && !recoveryFetching && (_jsx(Alert, { severity: "info", sx: { mb: 2 }, children: t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.') })), step === 'credentials' && (_jsx(LoginForm, { onSubmit: passwordLoginEnabled ? handleSubmitCredentials : null, onForgotPassword: passwordResetEnabled ? () => navigate('/reset-request-password') : null, onSocialLogin: socialLoginEnabled ? (provider) => startSocialLogin(provider) : null, socialProviders: socialProviders, onPasskeyLogin: passkeyLoginEnabled ? handlePasskeyLoginInitial : null, onSignUp: signupEnabled ? () => navigate('/signup') : null,
248
+ // Block submit while the handoff is in flight so the user does not
249
+ // race the recovery-token fetch with a normal password POST.
250
+ disabled: submitting || recoveryFetching, initialIdentifier: recoveryEmail })), step === 'mfa' && mfaState && (_jsx(Box, { children: _jsx(MfaLoginComponent, { availableTypes: mfaState.availableTypes, identifier: mfaState.identifier, onSuccess: handleMfaSuccess, onCancel: handleMfaCancel }) }))] }));
150
251
  }
@@ -59,8 +59,33 @@ export function PasswordInvitePage() {
59
59
  setSuccessKey(isInvite
60
60
  ? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
61
61
  : 'Auth.RESET_PASSWORD_SUCCESS_RESET');
62
- const target = nextPath
63
- ? `/login?next=${encodeURIComponent(nextPath)}`
62
+ // Same-origin check via URL parser — startsWith('/') alone misses
63
+ // bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
64
+ // for special schemes. The parser normalises backslashes and protocol-
65
+ // relative forms, so any cross-origin result is caught here.
66
+ let safeNextPath = null;
67
+ // Require leading-slash absolute path on the raw input (rejects
68
+ // relative inputs like `account/settings` and protocol-relative `//host`
69
+ // before they reach the URL parser, which would normalise both into a
70
+ // same-origin pathname starting with `/`).
71
+ if (nextPath &&
72
+ typeof nextPath === 'string' &&
73
+ nextPath.startsWith('/') &&
74
+ !nextPath.startsWith('//')) {
75
+ try {
76
+ const parsed = new URL(nextPath, window.location.origin);
77
+ // Catches WHATWG bypasses like `/\evil.com` that resolve to a
78
+ // different origin even though the raw input started with `/`.
79
+ if (parsed.origin === window.location.origin) {
80
+ safeNextPath = parsed.pathname + parsed.search + parsed.hash;
81
+ }
82
+ }
83
+ catch (_a) {
84
+ // fall through to default redirect
85
+ }
86
+ }
87
+ const target = safeNextPath
88
+ ? `/login?next=${encodeURIComponent(safeNextPath)}`
64
89
  : '/login';
65
90
  navigate(target);
66
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -10,6 +10,7 @@ import {
10
10
  fetchCurrentUser,
11
11
  logoutSession,
12
12
  } from './authApi';
13
+ import { ReauthModal } from './ReauthModal';
13
14
 
14
15
  export const AuthContext = createContext(null);
15
16
 
@@ -136,6 +137,7 @@ export const AuthProvider = ({ children }) => {
136
137
  }}
137
138
  >
138
139
  {children}
140
+ <ReauthModal />
139
141
  </AuthContext.Provider>
140
142
  );
141
143
  };
@@ -0,0 +1,79 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Dialog,
6
+ DialogActions,
7
+ DialogContent,
8
+ DialogTitle,
9
+ TextField,
10
+ } from '@mui/material';
11
+ import { useTranslation } from 'react-i18next';
12
+ import apiClient from './apiClient';
13
+ import { HEADLESS_BASE } from './authConfig.jsx';
14
+ import { rejectReauth, resolveReauth, subscribe } from './reauth';
15
+
16
+ export function ReauthModal() {
17
+ const { t } = useTranslation();
18
+ const [open, setOpen] = useState(false);
19
+ const [password, setPassword] = useState('');
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState(null);
22
+
23
+ useEffect(() => {
24
+ return subscribe((active) => {
25
+ setOpen(active);
26
+ if (!active) {
27
+ setPassword('');
28
+ setError(null);
29
+ setLoading(false);
30
+ }
31
+ });
32
+ }, []);
33
+
34
+ const handleSubmit = async (e) => {
35
+ e.preventDefault();
36
+ setLoading(true);
37
+ setError(null);
38
+ try {
39
+ await apiClient.post(`${HEADLESS_BASE}/auth/reauthenticate`, { password });
40
+ resolveReauth();
41
+ } catch {
42
+ setError(t('Auth.REAUTH_FAILED'));
43
+ setLoading(false);
44
+ }
45
+ };
46
+
47
+ const handleCancel = () => {
48
+ rejectReauth(new Error('cancelled'));
49
+ };
50
+
51
+ return (
52
+ <Dialog open={open} onClose={handleCancel} maxWidth="xs" fullWidth>
53
+ <form onSubmit={handleSubmit}>
54
+ <DialogTitle>{t('Auth.REAUTH_TITLE')}</DialogTitle>
55
+ <DialogContent>
56
+ {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
57
+ <TextField
58
+ autoFocus
59
+ fullWidth
60
+ type="password"
61
+ label={t('Auth.LOGIN_PASSWORD_LABEL')}
62
+ value={password}
63
+ onChange={(e) => setPassword(e.target.value)}
64
+ disabled={loading}
65
+ sx={{ mt: 1 }}
66
+ />
67
+ </DialogContent>
68
+ <DialogActions>
69
+ <Button onClick={handleCancel} disabled={loading}>
70
+ {t('Auth.MFA_TOTP_CANCEL_BUTTON')}
71
+ </Button>
72
+ <Button type="submit" variant="contained" disabled={loading || !password}>
73
+ {loading ? t('Auth.MFA_TOTP_VERIFY_BUTTON_LOADING') : t('Auth.MFA_VERIFY')}
74
+ </Button>
75
+ </DialogActions>
76
+ </form>
77
+ </Dialog>
78
+ );
79
+ }
@@ -1,5 +1,6 @@
1
1
  import axios from "axios";
2
2
  import { CSRF_URL } from "./authConfig";
3
+ import { requestReauth } from "./reauth";
3
4
 
4
5
  const apiClient = axios.create({
5
6
  withCredentials: true,
@@ -130,7 +131,7 @@ function extractAuthSignal(data) {
130
131
 
131
132
  apiClient.interceptors.response.use(
132
133
  (response) => response,
133
- (error) => {
134
+ async (error) => {
134
135
  const status = error?.response?.status ?? null;
135
136
  const data = error?.response?.data ?? {};
136
137
  const { code, i18nKey } = extractAuthSignal(data);
@@ -139,6 +140,24 @@ apiClient.interceptors.response.use(
139
140
  const isNotAuthenticated =
140
141
  code === "not_authenticated" || i18nKey === "auth.not_authenticated";
141
142
 
143
+ // Reauthentication gate: allauth returns 401 + flows[{id:"reauthenticate"}]
144
+ // when the session is stale before a sensitive operation (e.g. adding a passkey).
145
+ // Show the password-confirm modal, wait for it to complete, then retry once.
146
+ const flows = data?.data?.flows ?? data?.flows ?? [];
147
+ const needsReauth =
148
+ status === 401 &&
149
+ Array.isArray(flows) &&
150
+ flows.some((f) => f.id === "reauthenticate");
151
+
152
+ if (needsReauth && !error.config?._reauthRetried) {
153
+ try {
154
+ await requestReauth();
155
+ return apiClient({ ...error.config, _reauthRetried: true });
156
+ } catch {
157
+ return Promise.reject(error);
158
+ }
159
+ }
160
+
142
161
  // Per-request opt-out: bootstrap probes (e.g. fetchCurrentUser on app start)
143
162
  // expect to handle 401 silently and must not trigger a redirect-on-mount.
144
163
  // Carried as an axios config property, so it never travels to the backend
@@ -433,6 +433,31 @@ export async function rejectRecoveryRequest(id, supportNote) {
433
433
  return res.data;
434
434
  }
435
435
 
436
+ // S164: server-side session handoff. After the email-link redirect lands the
437
+ // SPA on `/login#recovery=ok`, the LoginPage fetches the plaintext recovery
438
+ // token from the user's server session (set by `recovery_complete_view`).
439
+ // The endpoint pops the token on every read — a single shot per browser
440
+ // navigation — so a navigated-away tab cannot reuse the entry.
441
+ export async function fetchRecoverySessionToken() {
442
+ try {
443
+ const res = await apiClient.get('/api/auth/recovery/session-token/', {
444
+ // Visiting `/login` without a pending recovery session is the common
445
+ // case; the 404 it returns must not bubble up as a global "session
446
+ // expired" auth error in the app shell.
447
+ skipAuthRedirect: true,
448
+ });
449
+ return res?.data?.token || null;
450
+ } catch (err) {
451
+ if (err?.response?.status === 404) {
452
+ // No pending recovery in this session — treat as a non-error so the
453
+ // LoginPage can decide whether to surface a "link expired" message
454
+ // based on the `#recovery=` hash sentinel it already inspects.
455
+ return null;
456
+ }
457
+ throw normaliseApiError(err, 'Auth.RECOVERY_TOKEN_INVALID');
458
+ }
459
+ }
460
+
436
461
  export async function loginWithRecoveryPassword(email, password, token) {
437
462
  try {
438
463
  // S50 (django-core-micha >=2.13.1): token wird im POST-Body übergeben,
@@ -0,0 +1,47 @@
1
+ // Promise broker for the reauthentication gate.
2
+ //
3
+ // When the response interceptor encounters a 401+reauthenticate, it calls
4
+ // requestReauth() and awaits the returned promise. The ReauthModal resolves
5
+ // or rejects that promise after the user submits (or cancels) the password
6
+ // dialog. Concurrent 401s share the same pending promise so only one modal
7
+ // ever appears.
8
+
9
+ let _pendingResolve = null;
10
+ let _pendingReject = null;
11
+ let _reauthPromise = null;
12
+ let _listeners = [];
13
+
14
+ export function requestReauth() {
15
+ if (_reauthPromise) return _reauthPromise;
16
+ _reauthPromise = new Promise((resolve, reject) => {
17
+ _pendingResolve = resolve;
18
+ _pendingReject = reject;
19
+ _listeners.forEach(fn => fn(true));
20
+ });
21
+ return _reauthPromise;
22
+ }
23
+
24
+ export function resolveReauth() {
25
+ const resolve = _pendingResolve;
26
+ _pendingResolve = null;
27
+ _pendingReject = null;
28
+ _reauthPromise = null;
29
+ _listeners.forEach(fn => fn(false));
30
+ if (resolve) resolve();
31
+ }
32
+
33
+ export function rejectReauth(error) {
34
+ const reject = _pendingReject;
35
+ _pendingResolve = null;
36
+ _pendingReject = null;
37
+ _reauthPromise = null;
38
+ _listeners.forEach(fn => fn(false));
39
+ if (reject) reject(error || new Error('Reauthentication cancelled'));
40
+ }
41
+
42
+ export function subscribe(fn) {
43
+ _listeners = [..._listeners, fn];
44
+ return () => {
45
+ _listeners = _listeners.filter(l => l !== fn);
46
+ };
47
+ }
@@ -6,6 +6,36 @@ export const authTranslations = {
6
6
  "en": "Email address or password is incorrect.",
7
7
  "sw": "Barua pepe au nenosiri sio sahihi."
8
8
  },
9
+ "Auth.RECOVERY_TOKEN_INVALID": {
10
+ "de": "Der Recovery-Link ist ungültig oder wurde bereits verwendet.",
11
+ "fr": "Le lien de récupération est invalide ou a déjà été utilisé.",
12
+ "en": "The recovery link is invalid or has already been used.",
13
+ "sw": "Kiungo cha urejesho si halali au tayari kimetumika."
14
+ },
15
+ "Auth.RECOVERY_TOKEN_EXPIRED": {
16
+ "de": "Der Recovery-Link ist abgelaufen. Bitte fordere einen neuen an.",
17
+ "fr": "Le lien de récupération a expiré. Veuillez en demander un nouveau.",
18
+ "en": "The recovery link has expired. Please request a new one.",
19
+ "sw": "Kiungo cha urejesho kimeisha muda wake. Tafadhali omba kipya."
20
+ },
21
+ "Auth.RECOVERY_LINK_VALIDATING": {
22
+ "de": "Recovery-Link wird geprüft…",
23
+ "fr": "Vérification du lien de récupération…",
24
+ "en": "Validating recovery link…",
25
+ "sw": "Inathibitisha kiungo cha urejesho..."
26
+ },
27
+ "Auth.RECOVERY_LOGIN_WARNING": {
28
+ "de": "Recovery-Link validiert. Bitte gib dein Passwort ein.",
29
+ "fr": "Lien de récupération validé. Veuillez saisir votre mot de passe.",
30
+ "en": "Recovery link validated. Please enter your password.",
31
+ "sw": "Kiungo cha urejesho kimethibitishwa. Tafadhali weka nenosiri lako."
32
+ },
33
+ "Auth.TWO_FACTOR_REQUIRED_HINT": {
34
+ "de": "Diese App benötigt zwei Authentifizierungsfaktoren für vollen Zugriff.",
35
+ "fr": "Cette application requiert deux facteurs d’authentification pour un accès complet.",
36
+ "en": "This app requires two authentication factors for full access.",
37
+ "sw": "Programu hii inahitaji vipengele viwili vya uthibitisho kwa ufikiaji kamili."
38
+ },
9
39
  "Auth.PASSKEY_FAILED": {
10
40
  "de": "Die Anmeldung mit Passkey ist fehlgeschlagen.",
11
41
  "fr": "La connexion avec passkey a échoué.",
@@ -1805,5 +1835,23 @@ export const authTranslations = {
1805
1835
  "fr": "Politique de code d'accès enregistrée.",
1806
1836
  "en": "Access code policy saved.",
1807
1837
  "sw": "Sera ya msimbo wa ufikiaji imehifadhiwa."
1838
+ },
1839
+ "Auth.REAUTH_TITLE": {
1840
+ "de": "Identität bestätigen",
1841
+ "fr": "Confirmer votre identité",
1842
+ "en": "Confirm your identity",
1843
+ "sw": "Thibitisha utambulisho wako"
1844
+ },
1845
+ "Auth.REAUTH_SUBTITLE": {
1846
+ "de": "Bitte gib dein Passwort ein, um fortzufahren.",
1847
+ "fr": "Veuillez saisir votre mot de passe pour continuer.",
1848
+ "en": "Please enter your password to continue.",
1849
+ "sw": "Tafadhali ingiza nenosiri lako ili kuendelea."
1850
+ },
1851
+ "Auth.REAUTH_FAILED": {
1852
+ "de": "Das Passwort ist falsch.",
1853
+ "fr": "Le mot de passe est incorrect.",
1854
+ "en": "The password is incorrect.",
1855
+ "sw": "Nenosiri si sahihi."
1808
1856
  }
1809
1857
  };
@@ -9,7 +9,11 @@ import { NarrowPage } from '../layout/PageLayout';
9
9
  import { AuthContext } from '../auth/AuthContext';
10
10
 
11
11
  // API & Services (Clean Architecture)
12
- import { loginWithRecoveryPassword, loginWithPassword } from '../auth/authApi';
12
+ import {
13
+ fetchRecoverySessionToken,
14
+ loginWithPassword,
15
+ loginWithRecoveryPassword,
16
+ } from '../auth/authApi';
13
17
  import { loginWithPasskey, startSocialLogin } from '../utils/authService';
14
18
 
15
19
  // Components
@@ -27,16 +31,23 @@ export function LoginPage() {
27
31
  const [submitting, setSubmitting] = useState(false);
28
32
  const [errorKey, setErrorKey] = useState(null);
29
33
  const [mfaState, setMfaState] = useState(null); // { availableTypes: [...], identifier }
34
+ // S164: token never travels through the URL/fragment anymore. It is held
35
+ // in component state only, fetched from the server-side session handoff
36
+ // endpoint when the redirect lands the SPA here with `#recovery=ok`.
37
+ const [recoveryToken, setRecoveryToken] = useState(null);
38
+ const [recoveryFetching, setRecoveryFetching] = useState(false);
30
39
 
31
40
  // URL Params parsing
32
41
  const params = new URLSearchParams(location.search);
33
42
  const hashParams = new URLSearchParams(
34
43
  String(location.hash || "").startsWith("#") ? String(location.hash).slice(1) : String(location.hash || ""),
35
44
  );
36
- const recoveryTokenRaw = hashParams.get('recovery') || params.get('recovery');
37
- const recoveryToken = ['invalid', 'expired'].includes(String(recoveryTokenRaw || '').toLowerCase())
38
- ? null
39
- : recoveryTokenRaw;
45
+ // After S164 the hash carries only a status sentinel: `ok`, `invalid`, or
46
+ // `expired`. We also accept the same key from query params as a defensive
47
+ // fallback for proxies that strip fragments.
48
+ const recoveryStatus = String(
49
+ hashParams.get('recovery') || params.get('recovery') || '',
50
+ ).toLowerCase();
40
51
  // Backward-compatible fallback for legacy links using query parameters.
41
52
  const recoveryEmail = hashParams.get('email') || params.get('email') || '';
42
53
  const requestedNext = params.get('next');
@@ -51,8 +62,30 @@ export function LoginPage() {
51
62
  return '/account?tab=security&from=weak_login';
52
63
  }
53
64
 
54
- if (requestedNext && requestedNext.startsWith('/')) {
55
- return requestedNext;
65
+ // Same-origin check via URL parser — `startsWith('/')` alone allows
66
+ // bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
67
+ // for special schemes. Resolve against the current origin and only accept
68
+ // results whose origin matches.
69
+ // Require leading-slash absolute path on the raw input (rejects relative
70
+ // inputs like `account/settings` and protocol-relative `//host` before
71
+ // they reach the URL parser, which would normalise both into a same-
72
+ // origin pathname starting with `/`).
73
+ if (
74
+ requestedNext &&
75
+ typeof requestedNext === 'string' &&
76
+ requestedNext.startsWith('/') &&
77
+ !requestedNext.startsWith('//')
78
+ ) {
79
+ try {
80
+ const parsed = new URL(requestedNext, window.location.origin);
81
+ // Catches WHATWG bypasses like `/\evil.com` that resolve to a
82
+ // different origin even though the raw input started with `/`.
83
+ if (parsed.origin === window.location.origin) {
84
+ return parsed.pathname + parsed.search + parsed.hash;
85
+ }
86
+ } catch {
87
+ // fall through to default redirect
88
+ }
56
89
  }
57
90
 
58
91
  return '/';
@@ -65,6 +98,77 @@ export function LoginPage() {
65
98
  }
66
99
  }, [location.search]);
67
100
 
101
+ // S164 handoff: on `#recovery=ok`, pull the plaintext token out of the
102
+ // server-side session (one-shot). On `invalid`/`expired`, or on any
103
+ // other non-empty value (legacy pre-S164 email links that carried the
104
+ // token directly in the hash), surface an invalid-link error so the
105
+ // user gets feedback instead of a silent login screen.
106
+ useEffect(() => {
107
+ if (!recoveryStatus) {
108
+ return;
109
+ }
110
+ // Strip the `recovery=…` sentinel from BOTH hash and query string so a
111
+ // bookmark or page refresh does not re-trigger this effect against an
112
+ // already-popped session entry (which would render a confusing
113
+ // "invalid" error to a user who is mid-login).
114
+ //
115
+ // We use `history.replaceState` rather than `navigate(..., {replace})`
116
+ // intentionally: react-router's `navigate` would trigger an immediate
117
+ // re-render with an updated `useLocation()`, which would cause this
118
+ // effect's cleanup to set `cancelled=true` and discard the in-flight
119
+ // recovery-token fetch result. `replaceState` only mutates the browser
120
+ // URL bar; `useLocation()` stays at `recovery=ok` for this render, the
121
+ // fetch resolves, and `recoveryToken` gets set normally. The trade-off
122
+ // is that `useLocation().hash` is stale for the page lifetime — fine
123
+ // because nothing else in this component reads it.
124
+ if (typeof window !== 'undefined' && window.history?.replaceState) {
125
+ const cleanParams = new URLSearchParams(window.location.search);
126
+ cleanParams.delete('recovery');
127
+ const cleanSearch = cleanParams.toString();
128
+ const cleanUrl =
129
+ window.location.pathname + (cleanSearch ? `?${cleanSearch}` : '');
130
+ window.history.replaceState(null, '', cleanUrl);
131
+ }
132
+ if (recoveryStatus === 'expired') {
133
+ setErrorKey('Auth.RECOVERY_TOKEN_EXPIRED');
134
+ setRecoveryToken(null);
135
+ return;
136
+ }
137
+ if (recoveryStatus !== 'ok') {
138
+ // Catches `invalid` and any unknown value (including legacy email
139
+ // links that carried the plaintext token before S164).
140
+ setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
141
+ setRecoveryToken(null);
142
+ return;
143
+ }
144
+ let cancelled = false;
145
+ setRecoveryFetching(true);
146
+ fetchRecoverySessionToken()
147
+ .then((token) => {
148
+ if (cancelled) return;
149
+ if (!token) {
150
+ // Session entry missing or already consumed — treat as invalid.
151
+ setErrorKey('Auth.RECOVERY_TOKEN_INVALID');
152
+ setRecoveryToken(null);
153
+ return;
154
+ }
155
+ setRecoveryToken(token);
156
+ })
157
+ .catch((err) => {
158
+ if (cancelled) return;
159
+ setErrorKey(err?.code || 'Auth.RECOVERY_TOKEN_INVALID');
160
+ setRecoveryToken(null);
161
+ })
162
+ .finally(() => {
163
+ if (!cancelled) {
164
+ setRecoveryFetching(false);
165
+ }
166
+ });
167
+ return () => {
168
+ cancelled = true;
169
+ };
170
+ }, [recoveryStatus]);
171
+
68
172
  useEffect(() => {
69
173
  if (loading || !user) return;
70
174
  navigate(getRedirectTarget(user), { replace: true });
@@ -185,13 +289,19 @@ export function LoginPage() {
185
289
  </Alert>
186
290
  )}
187
291
 
292
+ {recoveryFetching && !errorKey && (
293
+ <Alert severity="info" sx={{ mb: 2 }}>
294
+ {t('Auth.RECOVERY_LINK_VALIDATING', 'Validating recovery link…')}
295
+ </Alert>
296
+ )}
297
+
188
298
  {recoveryToken && !errorKey && (
189
299
  <Alert severity="info" sx={{ mb: 2 }}>
190
300
  {t('Auth.RECOVERY_LOGIN_WARNING', 'Recovery link validated. Please enter your password.')}
191
301
  </Alert>
192
302
  )}
193
303
 
194
- {twoFactorRequired && !recoveryToken && (
304
+ {twoFactorRequired && !recoveryToken && !recoveryFetching && (
195
305
  <Alert severity="info" sx={{ mb: 2 }}>
196
306
  {t('Auth.TWO_FACTOR_REQUIRED_HINT', 'This app requires two authentication factors for full access.')}
197
307
  </Alert>
@@ -207,7 +317,9 @@ export function LoginPage() {
207
317
  socialProviders={socialProviders}
208
318
  onPasskeyLogin={passkeyLoginEnabled ? handlePasskeyLoginInitial : null}
209
319
  onSignUp={signupEnabled ? () => navigate('/signup') : null}
210
- disabled={submitting}
320
+ // Block submit while the handoff is in flight so the user does not
321
+ // race the recovery-token fetch with a normal password POST.
322
+ disabled={submitting || recoveryFetching}
211
323
  initialIdentifier={recoveryEmail}
212
324
  />
213
325
  )}
@@ -70,8 +70,34 @@ export function PasswordInvitePage() {
70
70
  ? 'Auth.RESET_PASSWORD_SUCCESS_INVITE'
71
71
  : 'Auth.RESET_PASSWORD_SUCCESS_RESET',
72
72
  );
73
- const target = nextPath
74
- ? `/login?next=${encodeURIComponent(nextPath)}`
73
+ // Same-origin check via URL parser — startsWith('/') alone misses
74
+ // bypasses like `/\evil.com`, which WHATWG-parses to `https://evil.com/`
75
+ // for special schemes. The parser normalises backslashes and protocol-
76
+ // relative forms, so any cross-origin result is caught here.
77
+ let safeNextPath = null;
78
+ // Require leading-slash absolute path on the raw input (rejects
79
+ // relative inputs like `account/settings` and protocol-relative `//host`
80
+ // before they reach the URL parser, which would normalise both into a
81
+ // same-origin pathname starting with `/`).
82
+ if (
83
+ nextPath &&
84
+ typeof nextPath === 'string' &&
85
+ nextPath.startsWith('/') &&
86
+ !nextPath.startsWith('//')
87
+ ) {
88
+ try {
89
+ const parsed = new URL(nextPath, window.location.origin);
90
+ // Catches WHATWG bypasses like `/\evil.com` that resolve to a
91
+ // different origin even though the raw input started with `/`.
92
+ if (parsed.origin === window.location.origin) {
93
+ safeNextPath = parsed.pathname + parsed.search + parsed.hash;
94
+ }
95
+ } catch {
96
+ // fall through to default redirect
97
+ }
98
+ }
99
+ const target = safeNextPath
100
+ ? `/login?next=${encodeURIComponent(safeNextPath)}`
75
101
  : '/login';
76
102
  navigate(target);
77
103
  } catch (err) {