A second way in: signing in through an identity provider

OpenID Connect, off unless four settings are present. It is a second way to
the same token the password login mints — every route and the WebSocket
still know only a signed JWT — which is why it fits in one file and needs no
new dependency: Node imports a JWK itself, so checking an ID token is a
signature and five claims.

Authorization Code with PKCE, exchanged on the server so the client secret
stays there. The browser comes home with a one-time ticket rather than the
token, because a token in a redirect URL sits in the history, in the proxy's
log and in the referrer of the next request.

Somebody the app has not seen gets an account; an account that already
exists under that name is linked to the identity rather than duplicated, by
subject from then on. Never an administrator. An account made this way has
no password here, and the interface stops offering one — but an admin can
still set one, which is the way back in if the provider is ever gone.

The suite signs in at a provider of its own that signs real RS256 tokens,
and holds down the four ways the journey must fail as well as the way it
must work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 16:38:24 +02:00
1 parent c86c63922a
commit 43a98b0972
17 files changed
+1271 -44

No files matched your search

+45 -2
View File
@@ -50,6 +50,44 @@ const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
/** What went wrong out there, said in words rather than in the slug. */
const SIGN_IN_FAILURES = {
provider_refused: "The identity provider did not allow that sign-in.",
account_taken: "That identity belongs to a different account here.",
state_unknown: "That sign-in took too long. Try again.",
provider_unreachable: "The identity provider could not be reached.",
};
/**
* Coming back from the identity provider.
*
* The provider sends the browser to the app with a one-time ticket rather than
* with a token — a token in a URL lands in the history, in the proxy's log and
* in the referrer of the next request. The ticket is swapped here, and the
* address is cleaned either way: reloading the page must not try to redeem a
* ticket that is already spent, and a bookmarked address must not carry one.
*
* Answers with the signed-in user, or null when there was nothing to do.
*/
async function claimProviderSignIn(reportFailure) {
const params = new URLSearchParams(window.location.search);
const ticket = params.get("oidc");
const failure = params.get("oidc_error");
if (!ticket && !failure) return null;
window.history.replaceState({}, "", window.location.pathname);
if (failure) {
reportFailure(SIGN_IN_FAILURES[failure] || "That sign-in could not be completed. Try again.");
return null;
}
try {
return await sync.claimOidcTicket(ticket);
} catch {
reportFailure("That sign-in could not be completed. Try again.");
return null;
}
}
/**
* Root of the application.
*
@@ -133,11 +171,16 @@ export default function App() {
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
/* Why a sign-in through the provider failed, shown on the sign-in screen —
the failure happens while the browser is away, so there is nobody left
holding the request to report it to. */
const [signInError, setSignInError] = useState("");
/* ── Session ── */
useEffect(() => {
(async () => {
try {
const u = await sync.restoreSession();
const u = (await claimProviderSignIn(setSignInError)) || (await sync.restoreSession());
if (u) {
setUser(u);
await data.reload();
@@ -875,7 +918,7 @@ export default function App() {
</div>
);
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} initialError={signInError} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
+35
View File
@@ -354,6 +354,41 @@ export async function login(username: string, password: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/login", { username, password }));
}
/** The ways in that this server offers. */
export interface AuthMethods {
password: boolean;
oidc: { enabled: boolean; label?: string };
}
/**
* What the sign-in screen may offer, asked before anyone has signed in.
*
* Falls back to the password form on any failure: offline, or a server older
* than this route, must not leave the screen with no way in at all.
*/
export async function authMethods(): Promise<AuthMethods> {
try {
const m = await api.get<AuthMethods>("/auth/methods");
return { password: m?.password !== false, oidc: { enabled: !!m?.oidc?.enabled, label: m?.oidc?.label } };
} catch {
return { password: true, oidc: { enabled: false } };
}
}
/**
* Where the browser goes to sign in at the provider.
*
* A full page visit, not fetch: the provider needs its own screen, its own
* cookies and possibly a passkey prompt, and none of that can happen inside an
* XHR. It comes back to the app with a one-time ticket.
*/
export const oidcStartUrl = (): string => `${api.base}/auth/oidc/start`;
/** Swaps that ticket for the same token a password login would have produced. */
export async function claimOidcTicket(ticket: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/oidc/claim", { ticket }));
}
/* There is no register(): this instance has no public sign-up. Accounts are
* created by an administrator through the functions under "Administration". */
+7
View File
@@ -175,6 +175,13 @@ export interface User {
isAdmin?: boolean;
/** Interface settings, kept on the account so they follow you to the phone. */
preferences?: UserPreferences;
/**
* True when this account has no password of its own and signs in through the
* identity provider. The interface uses it to stop offering a password to
* change; an administrator can still set one, which is the way back in if the
* provider is ever unreachable.
*/
signsInWithProvider?: boolean;
/**
* How many versions a notebook keeps when it names no number of its own.
*
+39 -22
View File
@@ -24,6 +24,10 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView });
const [tab, setTab] = useState("profile"); // profile | username | password
/* An account made by the identity provider has no password here at all, so
the tab for changing one would lead to a form that can only fail. The
server refuses it too — this is the half that says why. */
const viaProvider = !!user?.signsInWithProvider;
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
const [usernamePass, setUsernamePass] = useState("");
@@ -88,7 +92,8 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
setSaving(false);
};
const saveUsername = async () => {
if (!newUsername || !usernamePass) return;
// The password is only part of this when there is one to give.
if (!newUsername || (!usernamePass && !viaProvider)) return;
clear();
setSaving(true);
try {
@@ -207,15 +212,17 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Username
</button>
<button
onClick={() => {
setTab("password");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</button>
{!viaProvider && (
<button
onClick={() => {
setTab("password");
clear();
}}
style={tbtn(tab === "password")}
>
Password
</button>
)}
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
@@ -378,18 +385,28 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
<div style={{ fontSize: 11, color: T.muted, marginTop: -6, marginBottom: 10 }}>
3–32 characters · letters, digits, . _ -
</div>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm Password
</label>
<input
type="password"
value={usernamePass}
onChange={(e) => setUsernamePass(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveUsername();
}}
/>
{viaProvider ? (
<div style={{ fontSize: 11, color: T.muted, marginBottom: 12, lineHeight: 1.5 }}>
You sign in through the identity provider, so there is no password to confirm
with. The name here is this app's own — changing it does not touch the one over
there.
</div>
) : (
<>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
Confirm Password
</label>
<input
type="password"
value={usernamePass}
onChange={(e) => setUsernamePass(e.target.value)}
style={inp}
onKeyDown={(e) => {
if (e.key === "Enter") saveUsername();
}}
/>
</>
)}
<button
onClick={saveUsername}
disabled={saving}
+18
View File
@@ -281,6 +281,24 @@ export function AdminPanel({ currentUser, onClose, dark }) {
ADMIN
</span>
)}
{/* Which accounts have no password here at all. Setting one
for such an account is deliberately still possible: it is
the way back in if the provider ever goes away. */}
{u.signsInWithProvider && (
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
padding: "2px 7px",
borderRadius: 6,
background: T.faint + "22",
color: T.muted,
}}
>
SSO
</span>
)}
{u.isSelf && <span style={{ fontSize: 11, color: T.faint }}>you</span>}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 3 }}>
+53 -6
View File
@@ -1,19 +1,32 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
/**
* Sign-in only.
*
* This is a private instance with no public sign-up: accounts are created by an
* administrator under Account → Administration. There is deliberately nothing
* here that hints at how, because that would only be useful to someone who has
* no account and should not get one.
* administrator under Account → Administration, or by the identity provider if
* one is configured. There is deliberately nothing here that hints at how,
* because that would only be useful to someone who has no account and should
* not get one — which is also why the provider is named and nothing else about
* it is.
*/
export function AuthScreen({ onLogin, dark }) {
export function AuthScreen({ onLogin, dark, initialError = "" }) {
const [username, setUsername] = useState("");
const [pass, setPass] = useState("");
const [err, setErr] = useState("");
const [err, setErr] = useState(initialError);
const [loading, setLoading] = useState(false);
/* Assume password only until the server says otherwise, so the form is never
missing while the answer is in flight. */
const [methods, setMethods] = useState({ password: true, oidc: { enabled: false } });
useEffect(() => {
let alive = true;
sync.authMethods().then((m) => alive && setMethods(m));
return () => {
alive = false;
};
}, []);
const T = useMemo(
() => ({
@@ -170,6 +183,40 @@ export function AuthScreen({ onLogin, dark }) {
>
{loading ? "..." : "Sign In"}
</button>
{/* The other way in. A whole-page visit rather than a request: the
provider needs its own screen, its own cookies, and on a phone the
passkey prompt that goes with them. */}
{methods.oidc?.enabled && (
<>
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "18px 0" }}>
<div style={{ flex: 1, height: 1, background: T.border }} />
<div style={{ fontSize: 11, color: T.muted, textTransform: "uppercase", letterSpacing: 0.6 }}>or</div>
<div style={{ flex: 1, height: 1, background: T.border }} />
</div>
<a
href={sync.oidcStartUrl()}
data-oidc-signin
style={{
display: "block",
width: "100%",
padding: "12px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
textAlign: "center",
textDecoration: "none",
boxSizing: "border-box",
}}
>
Continue with {methods.oidc.label || "single sign-on"}
</a>
</>
)}
</div>
</div>
);