Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ SUPER_ADMIN_IDS=
# dual Both login paths work; a session from either is accepted.
# This is where the rollout happens. Existing login cookies
# keep working for their full 90-day life.
# supertokens NOT USABLE YET. SuperTokens only; the old OAuth routes are
# switched off - but the client still points its login buttons
# at those routes and has no SuperTokens login flow, so the
# buttons silently do nothing and nobody can sign in. Existing
# sessions keep working, which is what makes it easy to miss.
# `dual` is the intended resting state for v1.8. See
# supertokens SuperTokens only; the old OAuth routes are switched off. The
# client gained a SuperTokens login flow and token refresh in
# v1.9, so this is implemented - but it has not yet been proven
# against a real core on a real deployment, because a v1.8 bug
# (providers registered under a key the SDK ignores) made every
# SuperTokens login impossible until v1.9 fixed it. Reach this
# mode through `dual`, never directly. See
# docs/authentication-methods.md Phase 5.
#
# Rolling back is setting this back to passport (or blanking it) and
Expand Down
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
# Changelog

## v1.9.0

- **Every SuperTokens login was impossible, and had been since v1.8.**
`server/supertokens/init.js` handed its OAuth providers to `ThirdParty.init`
as `signInUpFeature`. The SDK reads `signInAndUpFeature`. That key is
optional in its type definition and JavaScript does not reject unknown
properties, so the provider list was discarded without a throw, a warning or
a log line — while the boot log still printed `providers=github,discord`,
because it logs what was *built* rather than what the recipe *received*.

Every `GET /auth/authorisationurl` answered
`400 {"message":"the provider github could not be found in the
configuration"}`, for both providers. Nothing surfaced it: the client logged
in through passport, so the endpoint was never called — which is also why a
`dual` deployment running quietly in production proved less than it looked
like it did.

Fixed, with a test that runs the config `init.js` actually passes through the
SDK's own normaliser, so it fails on a wrong key *and* on a future SDK
rename rather than merely restating the fix. Confirmed against a real
SuperTokens core 12: the same request returns `200` with a valid GitHub
authorize URL, and restoring the typo reproduces production's 400 byte for
byte — while `GET /api/auth-info` reports `providers:["github","discord"]` in
both runs, which is exactly why nothing surfaced it for a release.

- **The client can log in through SuperTokens.** `client/src/game/auth.js`
drives the three-call flow — fetch the authorisation URL, handle the
`/auth/callback/<provider>` redirect, post `redirectURIInfo` to
`/auth/signinup` — hand-rolled rather than via `supertokens-web-js`, whose
`signOut()` targets the `/auth/signout` v1.8 deliberately removed for
clearing only half of a dual-stack session. Cancelled logins, unconfigured
providers and refused sign-ins each land back on the login screen with a
readable message.

- **Sessions refresh themselves.** A 401 triggers one
`POST /auth/session/refresh` and a retry. The refresh is serialised through a
single shared promise: SuperTokens rotates the refresh token on use, so
concurrent refreshes present an already-spent token and the core reads that
as token theft and revokes the session — turning a routine renewal into a
forced logout, and only ever under concurrency.

- **New public `GET /api/auth-info`** reports the auth mode, which login flow
the client should drive, and which providers actually have credentials. The
login screen needs all three *before* anyone is authenticated, so it could
not live on `/api/config`. One build therefore serves `passport` and
`supertokens` alike, and the documented rollback keeps working.

- `supertokens` mode is no longer described as unusable in the README,
`.env.example`, the Unraid template, the rollout runbook and the migration
guide. It is not yet described as proven either: every test stubs the core,
so the first real login is a gate to run on your own deployment — the four
checks are in `docs/authentication-methods.md` Phase 5 and runbook D6.

## v1.8.4

- **`AUTH_MODE=dual` would have refused to start against a correctly-secured
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
# published image's version label from that tag - so this literal only
# affects locally-built images, not what GHCR publishes.
LABEL org.opencontainers.image.version="1.8.4"
LABEL org.opencontainers.image.version="1.9.0"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ and the SuperTokens SDK is not even loaded.
|---|---|
| *(blank)* or `passport` | Default. Exactly as before; SuperTokens is not initialised. |
| `dual` | Both login paths live, sessions from either accepted. Where the rollout happens. |
| `supertokens` | ⚠️ **Not usable yet** — SuperTokens only; the legacy OAuth routes are not registered, and the client has no SuperTokens login flow, so **nobody can log in**. See below. |
| `supertokens` | SuperTokens only; the legacy OAuth routes are not registered. Implemented as of v1.9, but **not yet verified against a real core** — cut over via `dual` first. See below. |

Two properties worth knowing before you touch it:

Expand All @@ -247,13 +247,16 @@ Two properties worth knowing before you touch it:
looking like a finished rollout — the kind of thing you'd discover weeks
later, from the wrong symptom.

- **`dual` is the intended resting state.** `supertokens`-only mode is *not*
usable yet: `client/src/Login.jsx` points its buttons at the passport routes,
which that mode does not register, so they silently do nothing and no one can
sign in. Existing sessions keep working via the JWT fallback, which is what
makes it easy to miss. There is no token refresh in the client either. Both
are frontend work that has not been started — see
[`docs/authentication-methods.md`](./docs/authentication-methods.md) Phase 5.
- **Go through `dual` first.** As of v1.9 the client can log in through
SuperTokens and refreshes its own access token, so `supertokens`-only mode is
implemented rather than merely inadvisable. What it has *not* had is a real
login against a real core: v1.8 registered its OAuth providers under a key
the SDK ignores (`signInUpFeature` instead of `signInAndUpFeature`), so every
`/auth/authorisationurl` answered *"the provider github could not be found in
the configuration"* and no SuperTokens login was ever possible. v1.9 fixes
that, but the end-to-end proof still has to be run on your own deployment —
see [`docs/authentication-methods.md`](./docs/authentication-methods.md)
Phase 5.

`SUPERTOKENS_CONNECTION_URI` points at the SuperTokens core container and is
read only in `dual`/`supertokens`. That core needs its **own** database on
Expand Down
66 changes: 58 additions & 8 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,69 @@
import React, { useEffect, useState } from 'react';
import Login from './Login.jsx';
import RackStack from './RackStack.jsx';
import {
callbackProviderFromPath, completeSuperTokensLogin, fetchAuthInfo, FALLBACK_AUTH_INFO,
} from './game/auth.js';
import { configureAuthRefresh } from './game/api.js';

export default function App() {
const [status, setStatus] = useState('checking'); // checking | anon | authed
const [user, setUser] = useState(null);
const [authInfo, setAuthInfo] = useState(FALLBACK_AUTH_INFO);

useEffect(() => {
fetch('/api/me', { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error('not authenticated');
return r.json();
})
.then((u) => { setUser(u); setStatus('authed'); })
.catch(() => setStatus('anon'));
let cancelled = false;

(async () => {
// Started first and awaited later: it is independent of the callback
// exchange below, and one round trip here serves both the login screen
// (which buttons to draw) and api.js (whether to refresh on a 401).
const infoPromise = fetchAuthInfo();

// The SuperTokens redirect leg (v1.9). The provider sends the player to
// /auth/callback/<provider>?code=..., which the server does not handle -
// SuperTokens' ThirdParty recipe serves only POST /auth/callback/apple,
// so the request falls through to the SPA and lands here. Exchanging the
// code has to happen BEFORE /api/me, because it is what creates the
// session /api/me would otherwise report as absent.
let callbackFailure = null;
if (callbackProviderFromPath(window.location.pathname)) {
const result = await completeSuperTokensLogin();
if (cancelled) return;
if (!result.ok) callbackFailure = result;

// Replace rather than push, and always: leaving a spent ?code= in the
// URL means a reload re-POSTs an authorisation code the provider has
// already burned, which fails and bounces a logged-in player back to
// the login screen. Replacing also keeps the code out of the back
// button and out of any link the player might copy.
const next = result.ok
? '/'
: `/?authError=${encodeURIComponent(result.provider ?? '')}&authReason=${encodeURIComponent(result.reason)}`;
window.history.replaceState({}, '', next);
}

const info = await infoPromise;
if (cancelled) return;
const resolved = info && !info.error ? info : FALLBACK_AUTH_INFO;
configureAuthRefresh(resolved);
setAuthInfo(resolved);

// Deliberately skipped when the exchange just failed: there is no
// session to find, and asking anyway only delays the login screen.
let authed = null;
if (!callbackFailure) {
try {
const res = await fetch('/api/me', { credentials: 'include' });
if (res.ok) authed = await res.json();
} catch { /* offline or server down - treated as anonymous below */ }
}

if (cancelled) return;
if (authed) { setUser(authed); setStatus('authed'); } else setStatus('anon');
})();

return () => { cancelled = true; };
}, []);

if (status === 'checking') {
Expand All @@ -27,7 +77,7 @@ export default function App() {
);
}

if (status === 'anon') return <Login />;
if (status === 'anon') return <Login authInfo={authInfo} />;

return <RackStack user={user} />;
}
90 changes: 72 additions & 18 deletions client/src/Login.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
import React from 'react';
import React, { useState } from 'react';
import { Cpu } from 'lucide-react';
import { startSuperTokensLogin, loginErrorMessage, FALLBACK_AUTH_INFO } from './game/auth.js';

export default function Login() {
// Display order, independent of the order the server lists providers in.
// Keeps the screen looking the same as it did before v1.9 regardless of how
// configuredProviders() happens to sort.
const PROVIDER_STYLE = {
discord: { label: 'Continue with Discord', background: '#5865F2', color: '#fff' },
github: { label: 'Continue with GitHub', background: '#EAEFF5', color: '#0E141B' },
};
const DISPLAY_ORDER = ['discord', 'github'];

// `authInfo` comes from App, which fetches GET /api/auth-info once at boot and
// falls back to FALLBACK_AUTH_INFO when it cannot be reached - so this
// component always has a usable answer and never fetches it a second time.
export default function Login({ authInfo = FALLBACK_AUTH_INFO }) {
const params = new URLSearchParams(window.location.search);
const authError = params.get('authError');
const authReason = params.get('authReason');

const [failure, setFailure] = useState(null);
const [pending, setPending] = useState(null);
const info = authInfo;

// The redirect leg carries its failure in the URL (App.jsx puts it there
// before handing over); a failure to *start* the login is held in state.
const shownError = failure || (authError ? { provider: authError, reason: authReason } : null);

async function onSuperTokensLogin(providerId) {
setPending(providerId);
setFailure(null);
const res = await startSuperTokensLogin(providerId);
// Resolves only when the navigation never happened.
if (!res.ok) {
setFailure({ provider: providerId, reason: res.reason });
setPending(null);
}
}

const providers = DISPLAY_ORDER.filter((id) => info.providers?.includes(id));

return (
<div
Expand All @@ -15,26 +50,45 @@ export default function Login() {
<h1 className="text-2xl font-bold tracking-widest mb-1">RACKSTACK</h1>
<p className="text-sm mb-8" style={{ color: '#7C8AA0' }}>spare pi to hyperscale</p>

{authError && (
{shownError && (
<div className="text-xs mb-4 rounded-lg p-2" style={{ background: 'rgba(224,92,76,0.12)', border: '1px solid #E05C4C', color: '#E05C4C' }}>
Login with {authError} failed. Try again.
{loginErrorMessage(shownError.provider, shownError.reason)}
</div>
)}

<a
href="/auth/discord"
className="block w-full rounded-lg py-3 mb-3 text-sm font-semibold"
style={{ background: '#5865F2', color: '#fff' }}
>
Continue with Discord
</a>
<a
href="/auth/github"
className="block w-full rounded-lg py-3 text-sm font-semibold"
style={{ background: '#EAEFF5', color: '#0E141B' }}
>
Continue with GitHub
</a>
{providers.length === 0 && (
<div className="text-xs rounded-lg p-2" style={{ background: 'rgba(224,92,76,0.12)', border: '1px solid #E05C4C', color: '#E05C4C' }}>
No login provider is configured on this server.
</div>
)}

{providers.map((id) => {
const { label, ...css } = PROVIDER_STYLE[id];
const className = 'block w-full rounded-lg py-3 mb-3 text-sm font-semibold';

// passport mode is a plain link to a server route that 302s to the
// provider. SuperTokens needs a fetch first, to be told where to go.
if (info.loginFlow === 'passport') {
return (
<a key={id} href={`/auth/${id}`} className={className} style={css}>
{label}
</a>
);
}

return (
<button
key={id}
type="button"
disabled={pending !== null}
onClick={() => onSuperTokensLogin(id)}
className={className}
style={{ ...css, opacity: pending && pending !== id ? 0.5 : 1 }}
>
{pending === id ? 'Redirecting...' : label}
</button>
);
})}
</div>
</div>
);
Expand Down
Loading
Loading