fix(auth): encrypt stored cookie keys; tighten the link and sign-out flows

- Data-protection keys are encrypted (AES-256-GCM, key derived from the
  server secret) before they reach the DataProtectionKeys table.
- /link only follows local return paths and keeps the form when a
  signed-in visitor has an identity waiting to be linked.
- A failed sign-out says so instead of reloading as if it worked.
This commit is contained in:
Alexa
2026-10-25 15:52:29 +02:00
parent f0e4679887
commit 891ec89899
8 changed files with 150 additions and 13 deletions
+17 -2
View File
@@ -34,9 +34,15 @@ export function Header() {
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
}, []);
const [signOutError, setSignOutError] = useState<string | null>(null);
const doSignOut = async () => {
await signOut().catch(() => {});
window.location.assign('/');
setSignOutError(null);
try {
await signOut();
window.location.assign('/');
} catch (e) {
setSignOutError((e as Error).message);
}
};
// Sign in / account, shown only when the server has a sign-in provider.
@@ -136,6 +142,15 @@ export function Header() {
</div>
)}
{signOutError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
<span>Couldn't sign out ({signOutError}). You're still signed in; try again.</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => setSignOutError(null)}>Dismiss</button>
</div>
</div>
)}
{signinError && (
<div className="border-t border-danger/30 bg-danger/10">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-2 flex items-center justify-between gap-3 text-sm text-danger">
+7
View File
@@ -19,6 +19,13 @@ export function getMe(fresh = false): Promise<AccountMe> {
return cached;
}
/** `url` if it is a path on this site, else `/` (the server applies the same
* rule): never navigate to an address taken from the query string otherwise. */
export function localPath(url: string | null | undefined): string {
if (!url || !url.startsWith('/') || url.startsWith('//') || url.startsWith('/\\')) return '/';
return url;
}
/** Starts sign-in at the provider; comes back to `returnUrl` on this site. */
export function signInUrl(returnUrl = window.location.pathname + window.location.search): string {
return `/api/auth/external/login?returnUrl=${encodeURIComponent(returnUrl)}`;
+6 -3
View File
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate, useSearchParams } from 'react-router';
import { getMe, link, LINK_ERRORS, signInUrl, type AccountMe } from '../lib/account';
import { getMe, link, LINK_ERRORS, localPath, signInUrl, type AccountMe } from '../lib/account';
// After a first sign-in with the provider: prove which DorkNet account this
// identity belongs to, once, with that account's password.
export function Link() {
const [params] = useSearchParams();
const returnUrl = params.get('returnUrl') || '/';
const returnUrl = localPath(params.get('returnUrl'));
const navigate = useNavigate();
const [me, setMe] = useState<AccountMe | null>(null);
const [username, setUsername] = useState('');
@@ -16,7 +16,10 @@ export function Link() {
useEffect(() => {
getMe(true).then((m) => {
if (m.signedIn) navigate(returnUrl, { replace: true });
// Signed in with nothing waiting to be linked: nothing to do here. (A
// signed-in visitor can still link a new identity, e.g. a second
// account's.)
if (m.signedIn && !m.pendingLink) navigate(returnUrl, { replace: true });
else setMe(m);
}).catch((e) => setErr((e as Error).message));
// eslint-disable-next-line react-hooks/exhaustive-deps