feat(auth): external sign-in for the website and admin panel

Players sign in to the website, and admins to the admin panel, with an
OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the
provider identity (issuer + subject) to a DorkNet account with that
account's password; after that the provider sign-in is enough.

- Cookie sessions per host, keys persisted in the database so redeploys
  keep people signed in; unlink, deletion or a ban ends the session.
- Link attempts limited to 5 failures per 15 minutes per identity and
  per username.
- Admin panel: "Sign in with ..." exchanges the session for the usual
  admin tokens via GET /api/admin/v1/sso (IsAdmin still required).
- Downloads: optional DorkNet:Downloads:RequireSignIn serves the
  manifest and files only to signed-in players; the Downloads and Quest
  pages offer sign-in instead.
- Website: sign-in in the header, /link and /account pages.

The game client's login is unchanged.
This commit is contained in:
Alexa
2026-10-25 15:47:56 +02:00
parent 260f6605fc
commit f0e4679887
27 changed files with 5138 additions and 31 deletions
+4
View File
@@ -9,6 +9,8 @@ import { PlayerProfile } from './pages/PlayerProfile';
import { Rooms } from './pages/Rooms';
import { Join } from './pages/Join';
import { Downloads } from './pages/Downloads';
import { Link as LinkAccount } from './pages/Link';
import { Account } from './pages/Account';
import { NotFound } from './pages/NotFound';
// The Quest installer pulls in the WebUSB/ADB stack; load it only on /quest.
@@ -29,6 +31,8 @@ export function App() {
<Route path="/join" element={<Join />} />
<Route path="/quest" element={<Suspense fallback={null}><Quest /></Suspense>} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/link" element={<LinkAccount />} />
<Route path="/account" element={<Account />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
+37 -3
View File
@@ -1,7 +1,8 @@
import { NavLink, Link, useLocation, useNavigate } from 'react-router';
import { NavLink, Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { useEffect, useRef, useState } from 'react';
import { BrandMark } from './BrandMark';
import { getDownloads } from '../lib/downloads';
import { getDownloads, SignInRequiredError } from '../lib/downloads';
import { getMe, signInUrl, signOut, SIGNIN_ERRORS, type AccountMe } from '../lib/account';
const NAV = [
{ to: '/', label: 'Home', exact: true },
@@ -23,10 +24,31 @@ export function Header() {
const [hasDownloads, setHasDownloads] = useState(false);
const navItems = hasDownloads ? [...NAV, DOWNLOADS] : NAV;
const [me, setMe] = useState<AccountMe | null>(null);
const [params, setParams] = useSearchParams();
const signinError = params.get('signin_error');
useEffect(() => {
getDownloads().then((m) => setHasDownloads(m !== null)).catch(() => {});
getDownloads().then((m) => setHasDownloads(m !== null))
.catch((e) => setHasDownloads(e instanceof SignInRequiredError));
getMe().then(setMe).catch(() => { /* site works without sign-in */ });
}, []);
const doSignOut = async () => {
await signOut().catch(() => {});
window.location.assign('/');
};
// Sign in / account, shown only when the server has a sign-in provider.
const account = !me?.signInAvailable ? null : me.signedIn && me.player ? (
<span className="flex items-center gap-2 text-sm">
<Link to="/account" className="text-ink-200 hover:text-ink-50">{me.player.username}</Link>
<button type="button" onClick={doSignOut} className="btn-ghost !px-2 !py-1 text-xs">Sign out</button>
</span>
) : (
<a href={signInUrl()} className="btn-secondary !px-3 !py-1.5 text-sm">Sign in</a>
);
// Close the mobile drawer on route change so tapping a nav item
// doesn't leave a half-open overlay covering the page.
useEffect(() => { setOpen(false); }, [loc.pathname]);
@@ -72,6 +94,8 @@ export function Header() {
/>
</form>
<div className="hidden md:flex shrink-0">{account}</div>
<button
type="button"
onClick={() => setOpen(o => !o)}
@@ -107,6 +131,16 @@ export function Header() {
</NavLink>
))}
</nav>
{account && <div className="pt-1">{account}</div>}
</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">
<span>Sign-in didn't work: {SIGNIN_ERRORS[signinError] ?? signinError}</span>
<button type="button" className="btn-ghost !px-2 !py-1 text-xs" onClick={() => { params.delete('signin_error'); setParams(params, { replace: true }); }}>Dismiss</button>
</div>
</div>
)}
+46
View File
@@ -0,0 +1,46 @@
// Website sign-in through the server's OpenID Connect provider
// (ExternalSignInController). The session is an HttpOnly cookie on this host,
// so there is no token to keep here: same-origin fetches carry it.
import { get, post } from './api';
export interface AccountMe {
signInAvailable: boolean;
provider: string;
signedIn: boolean;
player: { id: string; username: string; email: string } | null;
pendingLink: { email: string } | null;
}
let cached: Promise<AccountMe> | null = null;
/** Who is signed in. Fetched once per page load; `fresh` re-reads it. */
export function getMe(fresh = false): Promise<AccountMe> {
if (!cached || fresh) cached = get<AccountMe>('/account/me').catch((e) => { cached = null; throw e; });
return cached;
}
/** 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)}`;
}
export const link = (username: string, password: string) =>
post<{ linked: boolean; username: string }>('/account/link', { username, password });
export const signOut = () => post('/account/logout', {});
export const unlink = () => post('/account/unlink', {});
// Server error code -> friendly copy.
export const LINK_ERRORS: Record<string, string> = {
invalid_credentials: "That username and password don't match a DorkNet account.",
missing_credentials: 'Enter your DorkNet username and password.',
too_many_attempts: 'Too many wrong attempts. Wait 15 minutes and try again.',
banned: 'That account is banned.',
identity_linked_to_another_account: 'This sign-in is already linked to a different DorkNet account.',
no_pending_identity: 'Your sign-in expired. Start again with "Sign in".',
};
export const SIGNIN_ERRORS: Record<string, string> = {
account_unavailable: 'The account linked to this sign-in is unavailable (deleted or banned).',
no_identity: "The sign-in provider didn't return an identity. Try again.",
provider_failed: 'The sign-in was cancelled or failed at the provider. Try again.',
};
+3 -3
View File
@@ -1,7 +1,7 @@
// Lightweight fetch wrapper for the public-facing site. Talks to the
// apex-hosted PublicSiteController at /api/site/v1/*. Anonymous-only —
// the public site has no login flow, so there's no Authorization
// header or token plumbing.
// apex-hosted PublicSiteController at /api/site/v1/*. No Authorization
// header or token plumbing: the optional sign-in (lib/account.ts) is an
// HttpOnly cookie that same-origin fetches send on their own.
const BASE = '/api/site/v1';
+7
View File
@@ -28,6 +28,12 @@ export interface DownloadsManifest {
updated?: string;
}
/** The server only serves downloads to signed-in players
* (DorkNet:Downloads:RequireSignIn) and nobody is signed in. */
export class SignInRequiredError extends Error {
constructor() { super('sign_in_required'); this.name = 'SignInRequiredError'; }
}
let cached: Promise<DownloadsManifest | null> | null = null;
/** The manifest, or null when this server doesn't offer downloads. Fetched
@@ -36,6 +42,7 @@ export function getDownloads(fresh = false): Promise<DownloadsManifest | null> {
if (!cached || fresh) {
cached = get<DownloadsManifest>('/downloads').catch((e) => {
if (e instanceof ApiError && e.status === 404) return null;
if (e instanceof ApiError && e.status === 401) { cached = null; throw new SignInRequiredError(); }
cached = null;
throw e;
});
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router';
import { getMe, signInUrl, signOut, unlink, type AccountMe } from '../lib/account';
// The signed-in account: which DorkNet account and provider email are
// linked, plus sign out and unlink.
export function Account() {
const [me, setMe] = useState<AccountMe | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
getMe(true).then(setMe).catch((e) => setErr((e as Error).message));
}, []);
const run = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
window.location.assign('/');
} catch (e) {
setErr((e as Error).message);
setBusy(false);
}
};
if (err) return <div className="max-w-lg mx-auto rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>;
if (!me) return <p className="max-w-lg mx-auto text-sm text-ink-400">Loading…</p>;
if (!me.signedIn || !me.player) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Account</h1>
<p>You're not signed in.</p>
{me.signInAvailable
? <a className="btn-primary inline-block" href={signInUrl('/account')}>Sign in with {me.provider}</a>
: <p className="text-ink-400">Sign-in isn't set up on this server.</p>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<h1 className="text-2xl font-semibold text-ink-50">Account</h1>
<div className="card !p-6 space-y-3 text-sm">
<div className="flex justify-between gap-3">
<span className="text-ink-400">DorkNet account</span>
<RouterLink className="text-brand-300 hover:text-brand-200" to={`/players/${me.player.id}`}>{me.player.username}</RouterLink>
</div>
<div className="flex justify-between gap-3">
<span className="text-ink-400">Signed in with {me.provider}</span>
<span className="text-ink-200">{me.player.email || '—'}</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<button type="button" className="btn-secondary" disabled={busy} onClick={() => run(signOut)}>Sign out</button>
<button type="button" className="btn-ghost text-danger" disabled={busy}
onClick={() => { if (window.confirm(`Unlink this sign-in from ${me.player!.username}? You'll need the account's password to link it again.`)) void run(unlink); }}>
Unlink
</button>
</div>
</div>
</div>
);
}
+14 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { fileUrl, getDownloads, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
import { fileUrl, getDownloads, SignInRequiredError, type DownloadFile, type DownloadRelease, type DownloadsManifest } from '../lib/downloads';
import { signInUrl } from '../lib/account';
const fmtSize = (n: number) =>
n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 2 ** 10))} KB`;
@@ -96,9 +97,13 @@ function FileCard({ f, rel }: { f: DownloadFile; rel: DownloadRelease }) {
export function Downloads() {
const [m, setM] = useState<DownloadsManifest | null | undefined>(undefined);
const [err, setErr] = useState<string | null>(null);
const [signInNeeded, setSignInNeeded] = useState(false);
useEffect(() => {
getDownloads(true).then(setM).catch((e) => setErr((e as Error).message));
getDownloads(true).then(setM).catch((e) => {
if (e instanceof SignInRequiredError) setSignInNeeded(true);
else setErr((e as Error).message);
});
}, []);
const releases = (m?.releases ?? []).filter((r) => r.files.length);
@@ -114,7 +119,13 @@ export function Downloads() {
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">Couldn't load the builds: {err}</div>}
{m === undefined && !err && <p className="text-sm text-ink-400">Loading builds…</p>}
{signInNeeded && (
<div className="card !p-5 flex flex-wrap items-center justify-between gap-3 text-sm text-ink-300">
<span>Sign in with your DorkNet account to see and download the builds.</span>
<a className="btn-primary" href={signInUrl('/downloads')}>Sign in</a>
</div>
)}
{m === undefined && !err && !signInNeeded && <p className="text-sm text-ink-400">Loading builds…</p>}
{m === null && <p className="text-sm text-ink-400">This server doesn't offer downloads.</p>}
{m && !releases.length && <p className="text-sm text-ink-400">No builds have been published yet.</p>}
+79
View File
@@ -0,0 +1,79 @@
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';
// 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 navigate = useNavigate();
const [me, setMe] = useState<AccountMe | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
getMe(true).then((m) => {
if (m.signedIn) navigate(returnUrl, { replace: true });
else setMe(m);
}).catch((e) => setErr((e as Error).message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setErr(null);
try {
await link(username.trim(), password);
await getMe(true);
window.location.assign(returnUrl); // the header re-reads who is signed in
} catch (e) {
const key = (e as Error).message;
setErr(LINK_ERRORS[key] ?? key);
} finally {
setBusy(false);
}
};
if (me && !me.pendingLink) {
return (
<div className="max-w-lg mx-auto card !p-6 space-y-3 text-sm text-ink-300">
<h1 className="text-xl font-semibold text-ink-50">Link your account</h1>
<p>Your sign-in expired before it was linked.</p>
{me.signInAvailable && <a className="btn-primary inline-block" href={signInUrl(returnUrl)}>Sign in again</a>}
</div>
);
}
return (
<div className="max-w-lg mx-auto space-y-4">
<div>
<h1 className="text-2xl font-semibold text-ink-50">Link your DorkNet account</h1>
<p className="text-sm text-ink-400">
You signed in{me?.pendingLink?.email ? <> as <span className="text-ink-200">{me.pendingLink.email}</span></> : null}.
Enter your DorkNet username and password once to connect them; after that, signing in is enough.
</p>
</div>
<form onSubmit={submit} className="card !p-6 space-y-4">
<div className="space-y-1.5">
<label className="label" htmlFor="username">DorkNet username</label>
<input id="username" className="input" autoComplete="username" required
value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="space-y-1.5">
<label className="label" htmlFor="password">Password</label>
<input id="password" type="password" className="input" autoComplete="current-password" required
value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{err && <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>}
<button type="submit" disabled={busy} className="btn-primary w-full">{busy ? 'Linking…' : 'Link account'}</button>
<p className="text-xs text-ink-500">
No account yet? <RouterLink className="text-brand-300 hover:text-brand-200" to="/join">Join with a signup code</RouterLink>, then come back here.
</p>
</form>
</div>
);
}
+13 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { fileSource, openXapk, urlSource, type Xapk } from '../lib/xapk';
import { connectHeadset, installXapk, webUsbSupported, type Headset, type Progress } from '../lib/questInstall';
import { fileUrl, getDownloads, questBuild, type QuestBuild } from '../lib/downloads';
import { fileUrl, getDownloads, questBuild, SignInRequiredError, type QuestBuild } from '../lib/downloads';
import { signInUrl } from '../lib/account';
const fmtSize = (n: number) =>
n >= 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`;
@@ -47,6 +48,8 @@ export function Quest() {
const fileRef = useRef<HTMLInputElement>(null);
// The server's latest published Quest build, if it offers downloads.
const [latest, setLatest] = useState<QuestBuild | null>(null);
// Downloads exist but need a signed-in player (DorkNet:Downloads:RequireSignIn).
const [signInForLatest, setSignInForLatest] = useState(false);
const [params] = useSearchParams();
const open = async (load: () => Promise<Xapk>) => {
@@ -71,7 +74,10 @@ export function Quest() {
// that card's own file, which may not be the newest release's.
const wanted = params.get('file');
if (wanted) void pickBuild(questBuild(m, wanted));
}).catch(() => { /* no downloads: only the file picker */ });
}).catch((e) => {
// No downloads: only the file picker. Sign-in required: offer it.
if (e instanceof SignInRequiredError) setSignInForLatest(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -140,6 +146,11 @@ export function Quest() {
) : (
<p className="text-sm text-ink-400">Pick the <code className="text-ink-200">DorkNet-….xapk</code> you were given. It stays on your computer; nothing is uploaded.</p>
)}
{signInForLatest && !latest && (
<p className="text-sm text-ink-400">
<a className="text-brand-300 hover:text-brand-200" href={signInUrl('/quest')}>Sign in</a> to install the latest build straight from this server.
</p>
)}
<input ref={fileRef} type="file" accept=".xapk,.zip" className="hidden" onChange={(e) => pickFile(e.target.files?.[0])} />
<div className="flex flex-wrap items-center gap-3">
{latest && (