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
+1250 -23

No files matched your search

+22
View File
@@ -31,3 +31,25 @@ INKNOTE_ADMIN_PASSWORD=
# VERSION_RETENTION_DAYS= # off: versions expire by count, not by age
# VERSION_COALESCE_MINUTES=5
# ALLOWED_ORIGINS= # only if the UI is on another host
# ── Signing in through an identity provider (OpenID Connect) ──
# Optional. Leave the four empty and the app is password-only; set all four and
# the sign-in screen offers the provider as well. The password login stays in
# either case — an unreachable provider must not lock anybody out of their own
# notes. Somebody who signs in this way and has no account here yet gets one
# (never an administrator: that is granted in the app, on purpose).
#
# In PocketID: a new OIDC client, redirect URI exactly
# https://<your-inknote-host>/api/auth/oidc/callback
# The *server container* has to reach the issuer itself, not just your browser.
#
# The first sign-in binds an identity to an account of the same name here, so
# this trusts the provider's usernames. Right for a provider whose names are set
# by its own administrator; wrong for one where people pick their own.
# INKNOTE_PUBLIC_URL=https://notes.example.com
# INKNOTE_OIDC_ISSUER=https://id.example.com
# INKNOTE_OIDC_CLIENT_ID=
# INKNOTE_OIDC_CLIENT_SECRET=
# INKNOTE_OIDC_LABEL=PocketID # what the button says: "Continue with …"
# INKNOTE_OIDC_SCOPES=openid profile email
# INKNOTE_OIDC_REDIRECT_URI= # only if it is not <public url>/api/auth/oidc/callback
+33
View File
@@ -14,6 +14,39 @@ a rebuild is not bit-identical.
---
## 2026-08-18 — A second way in
**Images** — web `sha256:232982cb…`, server `sha256:5ed577b1…`
The server image changed: OpenID Connect, and an `oidc_sub` column on users.
- **Signing in through an identity provider**, when one is configured — issuer,
client id and secret, and the address this app is reached at; without them
nothing about it appears anywhere. It is a second way to the *same* token the
password login mints, 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.
- **The browser is handed a one-time ticket, never the token.** The code is
exchanged on the server, so the client secret stays there, and a token in a
redirect URL would otherwise sit in the history, in the proxy's log and in the
referrer of the next request. The ticket is good for a minute and for one use.
- **An account is made for somebody the app has not seen**, and an account that
already exists under that name is linked to the identity rather than
duplicated — by subject from then on, so either side can be renamed. Never an
administrator: those rights are granted in the app, and a group at the provider
must not be a way around it.
- **Both doors stay open.** An account made this way has no password here at all
— the interface stops offering to change one and the login route refuses it as
it refuses a wrong one, at the same speed — but an administrator can still set
one, which is the way back in the day the provider is unreachable.
- **A suite that signs in at a provider of its own** (`e2e/oidc.mjs`), which
signs real RS256 tokens with a key InkNote fetches and checks. It holds down
the four ways the journey must fail as well as the way it must work: a refusal,
a replayed ticket, a token signed with an unpublished key, and one addressed to
another client.
---
## 2026-08-17 — Quotes that hold more than a sentence, and one list of marks
**Images** — web `sha256:30f67832…`, server `sha256:6ffe4b58…`
+5
View File
@@ -22,6 +22,11 @@ Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen:
- **Keine öffentliche Registrierung.** Konten legt ein Administrator an. Ein
Admin darf anlegen, löschen und Passwörter zurücksetzen – aber **keine**
weiteren Admins ernennen und **keine** fremden Konten umbenennen.
- **OIDC ist die zweite Anmeldeart, nicht die einzige.** Wer sich beim Anbieter
anmelden darf, bekommt hier ein Konto (Marcs Entscheidung, 18.08.2026) – aber
**nie** Admin-Rechte, die werden weiter nur in der App vergeben. Der
Passwort-Login bleibt immer erreichbar, sonst sperrt ein kaputter Anbieter
alle aus. Ohne `INKNOTE_OIDC_*` in der `.env` ist davon nichts zu sehen.
- **Bundle-Größe bleibt, wie sie ist.** Bewusst so entschieden.
- **Dashboard und Seitenleiste sind vertagt.** Die offenen Befunde dazu sind
bekannt; nicht ungefragt damit anfangen.
+330
View File
@@ -0,0 +1,330 @@
/*
* Signing in through an identity provider — OpenID Connect.
* ────────────────────────────────────────────────────────
*
* This is a second way to obtain the *same* token the password login hands out.
* Nothing downstream changes: every route, and the WebSocket, still knows only
* a signed JWT. That is the whole reason this fits in one file.
*
* Authorization Code with PKCE, and the code is exchanged **here**, never in
* the browser: the client secret stays on the server, and the browser is only
* ever handed a one-time ticket to swap for its token — a token in a redirect
* URL ends up in the history, in the proxy's log, and in the referrer of the
* next request.
*
* No library. Discovery is one document, the exchange is one form post, and
* checking the ID token is a signature and five claims — Node imports a JWK
* directly (`createPublicKey({ format: "jwk" })`), so this needs nothing that
* is not already installed. A dependency here would be one more thing to keep
* current in the part of the app that must not rot.
*
* What this file deliberately does *not* do:
* • refresh tokens — the provider is asked once, at sign-in, and InkNote's own
* token carries the session from then on. Nothing here needs the provider to
* be reachable afterwards, which is what keeps the app working offline.
* • decide who may in. That is an account question, and it is answered in
* server.js where the users are.
*/
const crypto = require("crypto");
/* ─── Configuration ─── */
const trimSlash = (s) => String(s || "").replace(/\/+$/, "");
const ISSUER = trimSlash(process.env.INKNOTE_OIDC_ISSUER);
const CLIENT_ID = process.env.INKNOTE_OIDC_CLIENT_ID || "";
const CLIENT_SECRET = process.env.INKNOTE_OIDC_CLIENT_SECRET || "";
const PUBLIC_URL = trimSlash(process.env.INKNOTE_PUBLIC_URL);
const REDIRECT_URI =
process.env.INKNOTE_OIDC_REDIRECT_URI || (PUBLIC_URL ? `${PUBLIC_URL}/api/auth/oidc/callback` : "");
const SCOPES = process.env.INKNOTE_OIDC_SCOPES || "openid profile email";
const LABEL = process.env.INKNOTE_OIDC_LABEL || "single sign-on";
/* Where the browser is sent back to with its ticket. The redirect URI names the
* API; the app itself sits at the root of the same site. Never anything read
* off the request: a redirect built from a parameter is an open redirect, and
* this one is reached without being signed in. */
const APP_URL = PUBLIC_URL || trimSlash(REDIRECT_URI).replace(/\/api\/auth\/oidc\/callback$/, "") || "";
const enabled = Boolean(ISSUER && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
/** Says what is missing, once, at boot — half a configuration is a typo, not a choice. */
function describeConfiguration() {
if (enabled) {
// The client secret and the code both travel to the issuer. Over plain HTTP
// they travel readably, which is worth saying out loud rather than assuming
// somebody meant it.
const insecure = ISSUER.startsWith("http://") ? "\n ⚠️ …over plain HTTP: the client secret and the code are readable in transit" : "";
return ` ✅ OpenID Connect enabled — ${ISSUER} (redirect ${REDIRECT_URI})${insecure}`;
}
const named = [
["INKNOTE_OIDC_ISSUER", ISSUER],
["INKNOTE_OIDC_CLIENT_ID", CLIENT_ID],
["INKNOTE_OIDC_CLIENT_SECRET", CLIENT_SECRET],
["INKNOTE_PUBLIC_URL or INKNOTE_OIDC_REDIRECT_URI", REDIRECT_URI],
];
if (named.every(([, v]) => !v)) return null; // not configured at all: nothing to say
const missing = named.filter(([, v]) => !v).map(([n]) => n);
return ` ⚠️ OpenID Connect is half configured and stays off — missing: ${missing.join(", ")}`;
}
/* ─── Small helpers ─── */
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const randomId = () => crypto.randomBytes(32).toString("base64url");
/** Fails loudly with a short slug: the browser is told the slug, the log the rest. */
function fail(code, detail) {
const err = new Error(detail ? `${code}: ${detail}` : code);
err.code = code;
return err;
}
async function getJson(url, options = {}) {
const res = await fetch(url, { ...options, signal: AbortSignal.timeout(8000) });
const text = await res.text();
let body = null;
try {
body = JSON.parse(text);
} catch {
/* left null — reported below with the status, which is the useful half */
}
if (!res.ok || !body) throw fail("provider_unreachable", `${url} → ${res.status} ${text.slice(0, 200)}`);
return body;
}
/* ─── Discovery and keys, both cached ───
*
* Cached because they are asked for on every sign-in and change about once a
* year. The key cache is also refreshed on demand: a provider may rotate its
* keys at any moment, and the first token signed by the new one would otherwise
* fail until the hour was up. That refresh is rate-limited, or an unknown `kid`
* — which is also what a forged token looks like — would be a way to make this
* server hammer the provider.
*/
const HOUR = 60 * 60 * 1000;
let discovery = { at: 0, doc: null };
let jwks = { at: 0, keys: [], lastForcedRefresh: 0 };
async function metadata() {
if (discovery.doc && Date.now() - discovery.at < HOUR) return discovery.doc;
const doc = await getJson(`${ISSUER}/.well-known/openid-configuration`);
for (const field of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
if (!doc[field]) throw fail("provider_incomplete", `discovery document has no ${field}`);
}
// The issuer identifies the provider and is checked against every token; a
// document that names someone else is either misconfigured or not theirs.
if (trimSlash(doc.issuer) !== ISSUER) {
throw fail("provider_incomplete", `discovery says issuer ${doc.issuer}, configured ${ISSUER}`);
}
discovery = { at: Date.now(), doc };
return doc;
}
async function signingKeys({ force = false } = {}) {
const fresh = Date.now() - jwks.at < HOUR;
if (jwks.keys.length && fresh && !force) return jwks.keys;
if (force && Date.now() - jwks.lastForcedRefresh < 60_000) return jwks.keys;
if (force) jwks.lastForcedRefresh = Date.now();
const doc = await metadata();
const body = await getJson(doc.jwks_uri);
const keys = Array.isArray(body.keys) ? body.keys : [];
jwks = { ...jwks, at: Date.now(), keys };
return keys;
}
/* ─── Pending sign-ins and one-time tickets ───
*
* Both live in memory on purpose. They exist for seconds, they are worthless
* afterwards, and writing them to the database would mean a table to clean up
* for something a restart is allowed to lose: a sign-in interrupted by a
* restart simply starts again.
*/
const PENDING_TTL = 10 * 60 * 1000;
const TICKET_TTL = 60 * 1000;
const pending = new Map(); // state -> { verifier, nonce, at }
const tickets = new Map(); // ticket -> { payload, at }
setInterval(() => {
const now = Date.now();
for (const [k, v] of pending) if (now - v.at > PENDING_TTL) pending.delete(k);
for (const [k, v] of tickets) if (now - v.at > TICKET_TTL) tickets.delete(k);
}, 60_000).unref();
/**
* Where to send the browser, and what to remember while it is away.
*
* PKCE as well as the secret: the code comes back through the browser, and a
* code that can be redeemed by whoever grabs it is worth as much as the token
* it buys. `state` ties the return to this attempt, `nonce` ties the ID token to
* it — different attacks, both cheap to prevent.
*/
async function beginLogin() {
const doc = await metadata();
const state = randomId();
const nonce = randomId();
const verifier = randomId();
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
pending.set(state, { verifier, nonce, at: Date.now() });
const url = new URL(doc.authorization_endpoint);
for (const [k, v] of Object.entries({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
})) {
url.searchParams.set(k, v);
}
return url.toString();
}
/** The signature, then the claims. Everything here is a reason to refuse. */
async function verifyIdToken(idToken, nonce) {
const parts = String(idToken).split(".");
if (parts.length !== 3) throw fail("id_token_invalid", "not three parts");
const [headerB64, payloadB64, signatureB64] = parts;
let header, claims;
try {
header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8"));
claims = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
} catch {
throw fail("id_token_invalid", "header or payload is not JSON");
}
const ALGORITHMS = {
RS256: { hash: "sha256" },
RS384: { hash: "sha384" },
RS512: { hash: "sha512" },
PS256: { hash: "sha256", padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 },
ES256: { hash: "sha256", dsaEncoding: "ieee-p1363" },
ES384: { hash: "sha384", dsaEncoding: "ieee-p1363" },
};
const alg = ALGORITHMS[header.alg];
// `none` is a valid JWT algorithm and an unforgeable way in if it is honoured.
if (!alg) throw fail("id_token_invalid", `unsupported algorithm ${header.alg}`);
const pick = (keys) =>
keys.find((k) => (header.kid ? k.kid === header.kid : true) && (!k.use || k.use === "sig"));
const attempt = async (force) => {
const jwk = pick(await signingKeys({ force }));
if (!jwk) return `no signing key for kid ${header.kid}`;
try {
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
const ok = crypto.verify(
alg.hash,
Buffer.from(`${headerB64}.${payloadB64}`),
{
key,
...(alg.padding ? { padding: alg.padding, saltLength: alg.saltLength } : {}),
...(alg.dsaEncoding ? { dsaEncoding: alg.dsaEncoding } : {}),
},
Buffer.from(signatureB64, "base64url")
);
return ok ? null : "signature does not match";
} catch (e) {
return `signature check failed: ${e.message}`;
}
};
/* Once with what we have, and if that fails once more with the key set read
* fresh. A provider may add a key, or replace one without renaming it, and
* either way the first token signed with it would otherwise be refused until
* the cached hour was up. The re-read is rate-limited inside `signingKeys`, so
* a stream of forged tokens cannot turn this into a way to hammer them. */
const problem = (await attempt(false)) && (await attempt(true));
if (problem) throw fail("id_token_invalid", problem);
const now = Math.floor(Date.now() / 1000);
const SKEW = 120; // clocks on two machines are never quite the same
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
if (trimSlash(claims.iss) !== ISSUER) throw fail("id_token_invalid", `issued by ${claims.iss}`);
if (!audiences.includes(CLIENT_ID)) throw fail("id_token_invalid", "not addressed to this client");
if (audiences.length > 1 && claims.azp !== CLIENT_ID) throw fail("id_token_invalid", "azp is someone else");
if (!claims.exp || claims.exp + SKEW < now) throw fail("id_token_invalid", "expired");
if (claims.iat && claims.iat - SKEW > now) throw fail("id_token_invalid", "issued in the future");
// The nonce is what makes this token *this* sign-in rather than one replayed
// from somewhere else.
if (claims.nonce !== nonce) throw fail("id_token_invalid", "nonce does not match");
if (!claims.sub) throw fail("id_token_invalid", "no subject");
return claims;
}
/**
* The return leg: state back to a pending attempt, code to tokens, token to
* claims. Gives back only what an account is made of.
*/
async function completeLogin({ code, state }) {
if (!code || !state) throw fail("callback_incomplete");
const attempt = pending.get(state);
// One attempt, one use — a code replayed with the same state gets nowhere.
pending.delete(state);
if (!attempt) throw fail("state_unknown", "no pending sign-in for this state, or it expired");
const doc = await metadata();
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: attempt.verifier,
});
let tokens;
try {
tokens = await getJson(doc.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: body.toString(),
});
} catch (e) {
throw fail("token_exchange_failed", e.message);
}
if (!tokens.id_token) throw fail("token_exchange_failed", "no id_token in the response");
const claims = await verifyIdToken(tokens.id_token, attempt.nonce);
return {
sub: String(claims.sub),
username: claims.preferred_username || null,
email: claims.email || null,
displayName: claims.name || claims.given_name || null,
};
}
/* ─── The ticket the browser carries home ─── */
function issueTicket(payload) {
const ticket = randomId();
tickets.set(ticket, { payload, at: Date.now() });
return ticket;
}
/** Single use: redeemed or gone, so a ticket left in a URL is worth nothing twice. */
function redeemTicket(ticket) {
const entry = tickets.get(String(ticket ?? ""));
if (!entry) return null;
tickets.delete(String(ticket));
if (Date.now() - entry.at > TICKET_TTL) return null;
return entry.payload;
}
module.exports = {
enabled,
label: LABEL,
issuer: ISSUER,
redirectUri: REDIRECT_URI,
appUrl: APP_URL,
describeConfiguration,
beginLogin,
completeLogin,
issueTicket,
redeemTicket,
};
+162 -6
View File
@@ -32,6 +32,8 @@ const path = require("path");
const fs = require("fs");
const http = require("http");
const oidc = require("./oidc");
/* ─────────── Config ─────────── */
const PORT = process.env.PORT || 4000;
@@ -276,6 +278,10 @@ const publicUser = (row) => ({
displayName: row.display_name,
isAdmin: !!row.is_admin,
preferences: readPreferences(row.preferences),
/* Whether this account has a password at all. The interface needs it to stop
* offering "change password" to somebody who signs in through the provider,
* and the administration list to show which accounts those are. */
signsInWithProvider: isProviderOnly(row),
});
/**
@@ -469,6 +475,16 @@ if (!tableColumns("notes").includes("icon_color")) {
db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT");
}
/* Which identity at the provider this account is, once it has signed in that
* way. Kept on the account rather than in a table of its own: a person has one
* provider here, and the column is what makes the link survive a rename — the
* subject is the provider's permanent name for someone, the username is not.
* Unique where it is set, so two accounts can never claim the same identity. */
if (!tableColumns("users").includes("oidc_sub")) {
db.exec("ALTER TABLE users ADD COLUMN oidc_sub TEXT");
}
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL");
/* Added after chapters shipped, so an existing table needs it too. */
if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
@@ -737,9 +753,12 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
const { username, password } = req.body;
const name = String(username ?? "").trim();
const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null;
// Always run a comparison so timing does not leak whether the user exists.
const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH);
if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" });
// Always run a comparison so timing does not leak whether the user exists —
// and an account that has no password of its own is compared against the
// dummy too, so it is exactly as slow and says exactly as little.
const usable = user && !isProviderOnly(user);
const ok = await bcrypt.compare(String(password ?? ""), usable ? user.password_hash : DUMMY_HASH);
if (!usable || !ok) return res.status(401).json({ error: "Invalid credentials" });
clearLoginAttempts(req);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
@@ -747,6 +766,128 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
} catch (err) { next(err); }
});
/* ─── Signing in through an identity provider ─── */
/**
* An account with no password of its own.
*
* Made by the provider, so there is nothing to compare against. The column is
* NOT NULL and bcrypt would happily compare against nonsense forever, so the
* marker is explicit rather than "some string that cannot match": a guard that
* can be read is worth more than one that merely happens to hold.
*/
const PROVIDER_ONLY = "!oidc:";
const isProviderOnly = (user) => String(user?.password_hash || "").startsWith(PROVIDER_ONLY);
/** A username this instance will accept, built from what the provider says. */
function usernameFromIdentity(identity) {
const raw = String(identity.username || (identity.email || "").split("@")[0] || identity.displayName || "user");
// The provider's rules are not this one's: anything outside the alphabet
// becomes a dash, and what is left is padded rather than rejected, because a
// name is a handle here and the identity is the subject.
let base = raw.trim().replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
while (base.length < 3) base += "0";
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(base)) return base;
// Taken by somebody else. Number it rather than fail: two people called "marc"
// at the provider is not an error, it is Tuesday.
for (let n = 2; n < 1000; n++) {
const candidate = `${base.slice(0, 28)}-${n}`;
if (!db.prepare("SELECT 1 FROM users WHERE username = ?").get(candidate)) return candidate;
}
throw new Error("could not find a free username");
}
/**
* Which account an arriving identity is.
*
* Three cases, in this order: known subject, an account of the same name that
* has not been claimed yet, or nobody — in which case one is made. Binding by
* name happens once and only for an unclaimed account; from then on the subject
* is the link, so renaming either side changes nothing.
*
* Administrator rights are never taken from the provider. A group over there
* would otherwise be a way to grant them here, and in this app an administrator
* cannot even appoint another one.
*/
function accountForIdentity(identity) {
const known = db.prepare("SELECT * FROM users WHERE oidc_sub = ?").get(identity.sub);
if (known) return known;
const wanted = normalizeUsername(identity.username) || normalizeUsername(String(identity.email || "").split("@")[0]);
const sameName = wanted ? db.prepare("SELECT * FROM users WHERE username = ?").get(wanted) : null;
if (sameName) {
// Already somebody else's identity: refuse rather than move the link, or a
// renamed account at the provider could take over an account here.
if (sameName.oidc_sub && sameName.oidc_sub !== identity.sub) {
const err = new Error("account_taken");
err.code = "account_taken";
throw err;
}
db.prepare("UPDATE users SET oidc_sub = ?, updated_at = datetime('now') WHERE id = ?").run(identity.sub, sameName.id);
console.log(` 🔗 linked account "${sameName.username}" to its identity at the provider`);
return { ...sameName, oidc_sub: identity.sub };
}
const id = uuid();
const username = usernameFromIdentity(identity);
db.prepare(
"INSERT INTO users (id, username, password_hash, display_name, is_admin, oidc_sub) VALUES (?, ?, ?, ?, 0, ?)"
).run(id, username, PROVIDER_ONLY + crypto.randomBytes(24).toString("hex"), identity.displayName || username, identity.sub);
console.log(` ✅ created account "${username}" for a new identity at the provider`);
return db.prepare("SELECT * FROM users WHERE id = ?").get(id);
}
/* What the sign-in screen may know before anyone has signed in: whether there is
* a second way in, and what to call it. Deliberately says nothing else — no
* issuer, no client id, nothing that describes the provider to a stranger. */
app.get("/api/auth/methods", (req, res) => {
res.json({
password: true,
oidc: oidc.enabled ? { enabled: true, label: oidc.label } : { enabled: false },
});
});
/* The browser is sent to the provider from here rather than from the app, so
* the app never has to know the issuer, the scopes or the redirect. */
app.get("/api/auth/oidc/start", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
try {
res.redirect(await oidc.beginLogin());
} catch (err) {
console.warn("[oidc] start failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "start_failed" }));
}
});
/** Home again, whatever happened — the app is the only place to report to. */
const backToApp = (params) => `${oidc.appUrl || ""}/?${new URLSearchParams(params)}`;
app.get("/api/auth/oidc/callback", async (req, res) => {
if (!oidc.enabled) return res.status(404).json({ error: "Not configured" });
if (req.query.error) {
console.warn(`[oidc] provider refused: ${req.query.error} ${req.query.error_description || ""}`);
return res.redirect(backToApp({ oidc_error: "provider_refused" }));
}
try {
const identity = await oidc.completeLogin({ code: req.query.code, state: req.query.state });
const user = accountForIdentity(identity);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
// The token itself never rides in the URL: a redirect lands in the history,
// in the proxy's log, and in the next request's referrer.
res.redirect(backToApp({ oidc: oidc.issueTicket({ token, user: signedInUser(user) }) }));
} catch (err) {
console.warn("[oidc] sign-in failed:", err.message);
res.redirect(backToApp({ oidc_error: err.code || "sign_in_failed" }));
}
});
/* The ticket is swapped for the token by the app itself, once, within a minute. */
app.post("/api/auth/oidc/claim", (req, res) => {
const payload = oidc.redeemTicket(req.body?.ticket);
if (!payload) return res.status(401).json({ error: "This sign-in has expired. Try again." });
res.json(payload);
});
/* ─── Auth profile routes ─── */
app.get("/api/auth/me", authMiddleware, (req, res) => {
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
@@ -786,10 +927,16 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
try {
const { newUsername, password } = req.body;
const name = normalizeUsername(newUsername);
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
if (!name) return res.status(400).json({ error: "Username and password required" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" });
/* An account made by the provider has no password to confirm with, and asking
* for one it cannot have would simply lock the name. The signed-in token is
* the only credential such an account has, and it is the same one that could
* already read every note. */
if (!isProviderOnly(user) && !(await bcrypt.compare(String(password), user.password_hash))) {
return res.status(401).json({ error: "Wrong password" });
}
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
if (exists) return res.status(409).json({ error: "Username already taken" });
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
@@ -800,6 +947,12 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const me = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(req.userId);
/* Nothing to change, and nothing to change it with. An administrator can
* still set one — that is the way back in if the provider is ever gone. */
if (isProviderOnly(me)) {
return res.status(400).json({ error: `This account signs in through ${oidc.label} and has no password.` });
}
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
if (String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
@@ -2120,7 +2273,10 @@ server.listen(PORT, () => {
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`);
console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`);
console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}`);
const sso = oidc.describeConfiguration();
if (sso) console.log(sso);
console.log("");
});
/* ─── Graceful shutdown ─── */
+19
View File
@@ -36,6 +36,25 @@ services:
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
- VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-}
# Signing in through an identity provider — see .env.example. Unset means
# the app is password-only and the sign-in screen shows nothing about it.
# Signing in through an identity provider. In development this points at
# the throwaway one the OIDC suite starts (e2e/fakeIdp.mjs) — which only
# exists while that suite runs, so the button on the sign-in screen is
# there the rest of the time and simply fails. That is the price of
# testing the real redirect against a real signature.
- INKNOTE_PUBLIC_URL=${INKNOTE_PUBLIC_URL:-http://localhost:3000}
- INKNOTE_OIDC_ISSUER=${INKNOTE_OIDC_ISSUER:-http://idp.test:3011}
- INKNOTE_OIDC_CLIENT_ID=${INKNOTE_OIDC_CLIENT_ID:-inknote-test}
- INKNOTE_OIDC_CLIENT_SECRET=${INKNOTE_OIDC_CLIENT_SECRET:-test-secret}
- INKNOTE_OIDC_LABEL=${INKNOTE_OIDC_LABEL:-Test IdP}
- INKNOTE_OIDC_SCOPES=${INKNOTE_OIDC_SCOPES:-}
- INKNOTE_OIDC_REDIRECT_URI=${INKNOTE_OIDC_REDIRECT_URI:-}
# The provider runs on the Docker host (inside the test container, which
# shares the host's network). Same name on both sides, or the issuer in the
# token would not match the one configured here.
extra_hosts:
- "idp.test:host-gateway"
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host where the host's own backup picks it up.
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"
+9
View File
@@ -34,6 +34,15 @@ services:
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
- VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-}
# Signing in through an identity provider — see .env.example. Unset means
# the app is password-only and the sign-in screen shows nothing about it.
- INKNOTE_PUBLIC_URL=${INKNOTE_PUBLIC_URL:-}
- INKNOTE_OIDC_ISSUER=${INKNOTE_OIDC_ISSUER:-}
- INKNOTE_OIDC_CLIENT_ID=${INKNOTE_OIDC_CLIENT_ID:-}
- INKNOTE_OIDC_CLIENT_SECRET=${INKNOTE_OIDC_CLIENT_SECRET:-}
- INKNOTE_OIDC_LABEL=${INKNOTE_OIDC_LABEL:-}
- INKNOTE_OIDC_SCOPES=${INKNOTE_OIDC_SCOPES:-}
- INKNOTE_OIDC_REDIRECT_URI=${INKNOTE_OIDC_REDIRECT_URI:-}
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"
# Bind mounts, so the data is a plain directory on the host.
# ${VAR:-fallback} means "VAR, or fallback if VAR is empty" — one or the
+196
View File
@@ -0,0 +1,196 @@
/*
* A provider to sign in at, for the OIDC suite.
*
* Small on purpose: discovery, an authorize page with two buttons, the token
* exchange, and a key set. It is a real OpenID Connect provider in the only
* sense that matters here — it signs a real RS256 ID token with a real key that
* InkNote fetches and checks, so the suite exercises the verification rather
* than stepping around it.
*
* It also does the things a provider should refuse, on request: sign with the
* wrong key, or return a token addressed to somebody else. A check that the
* good case works proves very little on its own.
*
* Bound to every interface, because two different machines talk to it: the
* browser (in this container) and the InkNote server (in another one, reaching
* it through the Docker host). Both must know it under the *same* name, or the
* issuer in the token will not match — see run.sh and docker-compose.dev.yml.
*/
import crypto from "node:crypto";
import http from "node:http";
const b64url = (buf) => Buffer.from(buf).toString("base64url");
const json = (res, body, status = 200) => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
};
/*
* The key this provider signs with, written out rather than generated.
*
* InkNote caches a provider's keys for an hour and only goes back for them when
* it meets a `kid` it does not know — which is right for a real provider, whose
* key does not change between two test runs, and fatal for one that made a new
* key every time: the second run of the day would be refused with a perfectly
* correct "signature does not match".
*
* It is a test key for a test provider on a test port. It signs nothing that
* exists outside this suite.
*/
const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC7T8xvhzB1EVAU
3+A1xOuwCz3OS2ALv9pgpMf8l+Hu6v09Ey3EhxcfGRzh+5c+wLXVD/f7q57VORo3
49fdLUWw9Nb7mKKugEsxOiD3O49jW4C71QILslYxuCkF71JLPFNzVVLiABs+C4zR
PVFmNJIAv27VDiK2w/eEKyNTY0p2776FliQ5U+7VXdM04fqQP0UXjrfcSS2mDBCb
Wa49reQJf7vZy0plyD2dTO/rq2O8UsQoOB+lHYAt4vgjWXdTrf1pcGWJlxO/n/0H
x4XmG0ItUKaD8HTbzO74I8mi/7/9rG2jWP1rK3U1Zozm0X0nhEtlxF7J7rtsFywY
bJ7Zk2hZAgMBAAECggEARdlpHAHm0REGsvIVUxe+6CQZBS/YfikqDzYDma8twoGA
s+DOBWZ2+dLMkXzXlsAYTn6o9AG4tjyYCVV+ruzt+yIidSojKD2RTKcnmeps+xkp
NWImZCQ5vkq1iZAIOSQi3nRn9v0hK471ESXzj9B0zbCVbP/bdBo51+CaSMNTBmyV
0W6kfn+F/ZPh2dJwAnyWyhmAtYItsZUfbh2jrReCY2ttQBnUzfhRXWWsjMpWIRi3
Uxe3Hru0qjHBXDUklchDmy01V/7OCNKvpB7PRgT0OdwYr4ClPDQCx8u7MunGwqL+
UM8NOb+JtC2IUoMtazJUa6EaR+UnkJ6feZzI6LUT/QKBgQD7rRczPaKLn8d4xktl
bKA+yVMvU92Lt7cipB2NNHei2wtNxTlzwmoyE7GdkYvqL0e4xUK0JhI4ho+/sZJh
6FGaRh3um54UXlqBMcidAFqz27VdF34WPFYNLkGv+C758Vi9a2UUTkcxKFkpGasn
Gcg0FwmOJVM5SEzcuSazsKXq8wKBgQC+h5+igKcV3vMNlF5lZ7PrY1DXc65V6n1E
1TtrPn8ikvcGqj84/ZZ4gIjwQjsDu8DISQr/DcITdRpt64YmpCMlv9+0b99P+p5m
0zG1rRxzeSAoQNiZNiVCr1gyNstEZN1c0XYAAMIUukahfRALwrcJHUAkfENwkKUn
baRl7tKagwKBgGRJhfwqLFJc+fx7b+HblkNOsDIkxhx9uaE5KnlxFdc0V93GoG0r
ZGvOAzHZpJpN+tN672if3xTdMfzeAom3LIVAOX8yTZyqOIBQ2TMOyBXxj+SnjtvH
cTbRXuWDDR0V+hmI6P9dber9vAGvs87h7DAtmeYlo3MpB0/RC8YAtyzXAoGADnUi
IuWaSCmTzer9Qrqcx3bQ0ph/mRtjCA8aJCgo44m9Xu8aPwvXGCvAuGJ+MelUVp5c
TEBSAsH3EHI3ysTXueyWYA3BJ0OAM5NBCWUqbIS7cPKCvRWDvEyr55uKzxTMS1fF
mXh1Qbdk1qozxoCu/O0QSfR39Zm0PoquV+HIb1sCgYBOB+7vCS89FeAtsZ0tvqhq
c9mYiRxBBAd1li4tmp3KJW6qY4kceeXSmcTn5WHKeB7qft3fceOGF2UyJAjvllkB
wH6nPXH9qF4cf5PWLg7Mzwpa8t+sVdmVdwn5Sqx/5ms5hXIeCMEfko944KTmJjCN
wBfCzAoruyg8JjnOuMJ1dw==
-----END PRIVATE KEY-----`;
export async function startFakeIdp({ port = 3011, issuer, clientId, clientSecret }) {
const real = {
privateKey: crypto.createPrivateKey(TEST_PRIVATE_KEY),
publicKey: crypto.createPublicKey(TEST_PRIVATE_KEY),
};
const other = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); // never published
const kid = "test-key-1";
/* Who the next sign-in is, and what the provider should do wrong this time.
Set by the suite through POST /who before each attempt. */
let identity = { sub: "subject-1", preferred_username: "someone", name: "Some One" };
let misbehave = { wrongKey: false, wrongAudience: false };
const codes = new Map(); // code -> { challenge, nonce, identity }
const idToken = ({ nonce, sub, ...rest }) => {
const key = misbehave.wrongKey ? other.privateKey : real.privateKey;
const header = b64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(
JSON.stringify({
iss: issuer,
sub,
aud: misbehave.wrongAudience ? "somebody-else" : clientId,
exp: now + 300,
iat: now,
nonce,
...rest,
})
);
const signature = crypto.sign("sha256", Buffer.from(`${header}.${payload}`), key);
return `${header}.${payload}.${b64url(signature)}`;
};
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, issuer);
if (url.pathname === "/.well-known/openid-configuration") {
return json(res, {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
jwks_uri: `${issuer}/jwks`,
response_types_supported: ["code"],
id_token_signing_alg_values_supported: ["RS256"],
});
}
if (url.pathname === "/jwks") {
const jwk = real.publicKey.export({ format: "jwk" });
return json(res, { keys: [{ ...jwk, kid, use: "sig", alg: "RS256" }] });
}
/* The suite says who is about to sign in, and how the provider should behave. */
if (url.pathname === "/who" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(JSON.parse(d || "{}")));
});
identity = { ...identity, ...(body.identity || {}) };
misbehave = { wrongKey: false, wrongAudience: false, ...(body.misbehave || {}) };
return json(res, { ok: true, identity, misbehave });
}
if (url.pathname === "/authorize") {
const back = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const code = crypto.randomBytes(16).toString("hex");
codes.set(code, {
challenge: url.searchParams.get("code_challenge"),
nonce: url.searchParams.get("nonce"),
identity,
});
// A page with a button rather than an immediate redirect: this is where a
// person types a password or taps a passkey, and the suite should have to
// go through it like one.
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
return res.end(`<!doctype html><meta charset="utf-8"><title>Test IdP</title>
<body style="font-family:sans-serif;padding:40px">
<h1>Test identity provider</h1>
<p>Signing in as <b>${identity.preferred_username}</b></p>
<a data-idp-approve href="${back}?code=${code}&state=${encodeURIComponent(state)}">Approve</a>
<a data-idp-deny href="${back}?error=access_denied&state=${encodeURIComponent(state)}">Deny</a>
</body>`);
}
if (url.pathname === "/token" && req.method === "POST") {
const body = await new Promise((resolve) => {
let d = "";
req.on("data", (c) => (d += c));
req.on("end", () => resolve(new URLSearchParams(d)));
});
const entry = codes.get(body.get("code"));
codes.delete(body.get("code")); // one exchange per code, like the real thing
if (!entry) return json(res, { error: "invalid_grant" }, 400);
if (body.get("client_secret") !== clientSecret) return json(res, { error: "invalid_client" }, 401);
const verifier = body.get("code_verifier") || "";
const computed = b64url(crypto.createHash("sha256").update(verifier).digest());
if (computed !== entry.challenge) return json(res, { error: "invalid_grant" }, 400);
return json(res, {
access_token: "not-used-by-inknote",
token_type: "Bearer",
expires_in: 300,
id_token: idToken({ nonce: entry.nonce, ...entry.identity }),
});
}
json(res, { error: "not_found" }, 404);
});
await new Promise((resolve) => server.listen(port, "0.0.0.0", resolve));
return {
/** Sets who signs in next, and whether the provider misbehaves. */
async setNext(body) {
const r = await fetch(`${issuer}/who`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return r.json();
},
async stop() {
await new Promise((resolve) => server.close(resolve));
},
};
}
+229
View File
@@ -0,0 +1,229 @@
/* Signing in through an identity provider.
*
* The provider is real enough to be worth testing against: it signs a real
* RS256 token with a key InkNote fetches and checks (e2e/fakeIdp.mjs). So the
* claims under test are not "a button exists" but:
*
* • the whole redirect journey ends in a signed-in app, with an account that
* did not exist before — and never an administrator;
* • the same person coming back a second time is the same account, and an
* account that already existed under that name is linked rather than
* duplicated;
* • the token in the URL is a one-time ticket, not the session token, and the
* address is cleaned behind it;
* • a refusal at the provider, a token signed with the wrong key and a token
* addressed to somebody else all end at the sign-in screen, signed out.
*
* The last group is the point of the exercise. A sign-in that only works is not
* evidence of anything.
*/
import { BASE, api, check, launch, newPage, report } from "./lib.mjs";
import { startFakeIdp } from "./fakeIdp.mjs";
const ISSUER = "http://idp.test:3011";
const ADMIN = {
user: process.env.INKNOTE_ADMIN || "marc",
pass: process.env.INKNOTE_ADMIN_PASS || "geheim123",
};
const unique = Math.floor(Math.random() * 1e6);
const idp = await startFakeIdp({
port: 3011,
issuer: ISSUER,
clientId: "inknote-test",
clientSecret: "test-secret",
});
const browser = await launch();
let adminPage;
const madeUsers = [];
try {
/* An administrator, to look at the accounts this creates and to clean up. */
adminPage = await newPage(browser);
await adminPage.goto(BASE, { waitUntil: "networkidle" });
await adminPage.getByPlaceholder("Username").fill(ADMIN.user);
await adminPage.getByPlaceholder("Password").fill(ADMIN.pass);
await adminPage.getByRole("button", { name: "Sign In" }).click();
await adminPage.waitForSelector("text=Dashboard", { timeout: 20000 });
const accounts = async () => (await api(adminPage, "GET", "/admin/users")).body;
const accountNamed = async (name) => (await accounts()).find((u) => u.username === name);
/* ── the offer ── */
const page = await newPage(browser);
const seen = [];
page.on("framenavigated", (f) => f === page.mainFrame() && seen.push(f.url()));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
const methods = await page.evaluate(async () => (await fetch("/api/auth/methods")).json());
check(methods?.oidc?.enabled === true, "the server says there is a second way in", JSON.stringify(methods));
check(
!JSON.stringify(methods).includes("secret") && !JSON.stringify(methods).includes("client"),
"and says nothing else about it to a stranger",
JSON.stringify(methods)
);
check((await page.locator("[data-oidc-signin]").count()) === 1, "the sign-in screen offers it");
check(
(await page.locator("[data-oidc-signin]").innerText()).includes("Test IdP"),
"under the name it was given",
await page.locator("[data-oidc-signin]").innerText()
);
check((await page.getByPlaceholder("Password").count()) === 1, "and the password form is still there");
/* ── someone the app has never seen ── */
const newcomer = `sso-newcomer-${unique}`;
await idp.setNext({
identity: { sub: `subject-new-${unique}`, preferred_username: newcomer, name: "A Newcomer" },
});
await page.locator("[data-oidc-signin]").click();
await page.waitForLoadState("networkidle");
check(page.url().startsWith(`${ISSUER}/authorize`), "pressing it leads to the provider", page.url());
const authorizeUrl = new URL(page.url());
check(
authorizeUrl.searchParams.get("code_challenge_method") === "S256" &&
!!authorizeUrl.searchParams.get("state") &&
!!authorizeUrl.searchParams.get("nonce"),
"with PKCE, a state and a nonce",
authorizeUrl.search.slice(0, 120)
);
await page.locator("[data-idp-approve]").click();
await page.waitForSelector("text=Dashboard", { timeout: 20000 });
await page.waitForTimeout(1500);
check(true, "approving there signs in here");
const created = await accountNamed(newcomer);
madeUsers.push(created?.id);
check(!!created, "an account was made for them", newcomer);
check(created?.isAdmin === false, "and it is not an administrator");
check(created?.signsInWithProvider === true, "and has no password of its own");
/* The address is cleaned, and what was in it was a ticket, not the token. */
const returned = seen.find((u) => u.includes("oidc="));
check(!!returned, "the provider sent the browser home with something in the URL");
check(
!!returned && !returned.includes(await page.evaluate(() => localStorage.getItem("inknote_token"))),
"which was not the session token"
);
check(!page.url().includes("oidc"), "and the address was cleaned afterwards", page.url());
/* A session lives in two places — IndexedDB and localStorage — so "signed out
again" means a browser that never saw this one. Every case below gets one. */
const signedOutBrowser = () => newPage(browser);
/* ── the ticket is single use ── */
const replay = await signedOutBrowser();
await replay.goto(returned, { waitUntil: "networkidle" });
await replay.waitForTimeout(2000);
check(
(await replay.getByPlaceholder("Password").count()) === 1,
"the same ticket a second time is worth nothing",
replay.url()
);
await replay.context().close();
/* ── the same person again is the same account ── */
const again = await signedOutBrowser();
await again.goto(BASE, { waitUntil: "networkidle" });
await again.locator("[data-oidc-signin]").click();
await again.waitForLoadState("networkidle");
await again.locator("[data-idp-approve]").click();
await again.waitForSelector("text=Dashboard", { timeout: 20000 });
await again.waitForTimeout(1200);
const afterSecond = (await accounts()).filter((u) => u.username.startsWith(`sso-newcomer-${unique}`));
check(afterSecond.length === 1, "signing in again does not make a second account", `${afterSecond.length}`);
await again.context().close();
/* ── an account that already existed here ── */
const existing = `sso-existing-${unique}`;
const madeByAdmin = await api(adminPage, "POST", "/admin/users", {
username: existing,
password: "e2epass123",
displayName: "Already Here",
});
madeUsers.push(madeByAdmin.body?.id);
check(madeByAdmin.status === 201, "an ordinary account exists under that name");
await idp.setNext({
identity: { sub: `subject-existing-${unique}`, preferred_username: existing, name: "Already Here" },
});
const second = await signedOutBrowser();
await second.goto(BASE, { waitUntil: "networkidle" });
await second.locator("[data-oidc-signin]").click();
await second.waitForLoadState("networkidle");
await second.locator("[data-idp-approve]").click();
await second.waitForSelector("text=Dashboard", { timeout: 20000 });
await second.waitForTimeout(1200);
const linked = (await accounts()).filter((u) => u.username === existing);
check(linked.length === 1, "the identity is linked to it rather than duplicated", `${linked.length}`);
check(
linked[0]?.signsInWithProvider === false,
"and it keeps the password it already had — one way in does not close the other"
);
await second.context().close();
/* ── the ways it must fail ── */
const signedOut = async (page) => (await page.getByPlaceholder("Password").count()) === 1;
const denied = await signedOutBrowser();
await denied.goto(BASE, { waitUntil: "networkidle" });
await denied.locator("[data-oidc-signin]").click();
await denied.waitForLoadState("networkidle");
await denied.locator("[data-idp-deny]").click();
await denied.waitForTimeout(2000);
check(await signedOut(denied), "a refusal at the provider ends signed out");
check(
(await denied.locator("body").innerText()).includes("did not allow"),
"and says so on the sign-in screen",
(await denied.locator("body").innerText()).slice(0, 120)
);
await denied.context().close();
const refusedBy = async (label, misbehave) => {
await idp.setNext({
identity: { sub: `subject-forged-${unique}`, preferred_username: `sso-forged-${unique}`, name: "Forged" },
misbehave,
});
const forged = await signedOutBrowser();
await forged.goto(BASE, { waitUntil: "networkidle" });
await forged.locator("[data-oidc-signin]").click();
await forged.waitForLoadState("networkidle");
await forged.locator("[data-idp-approve]").click();
await forged.waitForTimeout(2000);
check(await signedOut(forged), label);
check(
!(await accounts()).some((u) => u.username === `sso-forged-${unique}`),
`${label} — and no account was made from it`
);
await forged.context().close();
};
await refusedBy("a token signed with a key the provider does not publish is refused", { wrongKey: true });
await refusedBy("a token addressed to another client is refused", { wrongAudience: true });
/* ── an account made this way has no password ── */
const tryPassword = await adminPage.evaluate(async (name) => {
const r = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: name, password: "" }),
});
return r.status;
}, newcomer);
check(tryPassword === 401, "and cannot be signed into with a password", `${tryPassword}`);
} catch (e) {
check(false, "suite ran to the end", e.stack || e.message);
} finally {
/* Tidy up whatever the run made, including the accounts the provider caused. */
try {
for (const id of madeUsers.filter(Boolean)) await api(adminPage, "DELETE", `/admin/users/${id}`);
await adminPage?.context().close();
} catch {
/* the report matters more than the cleanup */
}
await browser.close();
await idp.stop();
process.exit(report());
}
+2 -1
View File
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin oidc editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync)
mkdir -p shots
if [ ! -d node_modules ]; then
@@ -32,6 +32,7 @@ for s in "${SUITES[@]}"; do
echo
echo "═══ $s ═══"
docker run --rm --network host \
--add-host idp.test:127.0.0.1 \
-v "$PWD:/w" -v "$PWD/shots:/out" -w /w \
-e INKNOTE_URL="$URL" \
-e INKNOTE_ADMIN="${INKNOTE_ADMIN:-marc}" \
+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.
*
+18 -1
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,6 +212,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Username
</button>
{!viaProvider && (
<button
onClick={() => {
setTab("password");
@@ -216,6 +222,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
Password
</button>
)}
</div>
<div style={{ padding: "18px 22px 22px" }}>
{msg && (
@@ -378,6 +385,14 @@ 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>
{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>
@@ -390,6 +405,8 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
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>
);
+67 -7
View File
@@ -28,8 +28,9 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
sign-up: an administrator creates the accounts
- **JWT Authentication** — Username + password accounts with bcrypt password hashing, and
optionally an identity provider (OpenID Connect). No public sign-up: accounts come from an
administrator or from the provider
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
### Architecture
@@ -180,9 +181,13 @@ otherwise the app is reachable on the host's address without TLS.
| GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` |
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
| GET | `/api/auth/methods` | — *(no token needed)* | `{password, oidc:{enabled,label}}` |
| GET | `/api/auth/oidc/start` | — | redirect to the provider |
| GET | `/api/auth/oidc/callback` | *(the provider's `code` and `state`)* | redirect home with a one-time ticket |
| POST | `/api/auth/oidc/claim` | `{ticket}` | `{token, user}` |
**There is no registration endpoint.** This is a private instance: accounts are created by an
administrator, and nothing on the login screen suggests otherwise.
administrator or by the identity provider, and nothing on the login screen suggests otherwise.
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
Servers created before this carried e-mail logins; on first start they migrate automatically,
@@ -217,6 +222,59 @@ Deleting an account destroys its notes, notebooks, drawings and version history
deleted person's session stops working immediately: tokens are signed rather than stored, so
`authMiddleware` checks on every request that the account still exists.
### Signing in through an identity provider
Optional, and off unless four settings are present (`.env.example` lists them):
the issuer, a client id and secret, and the public URL this app is reached at.
The provider is only ever named on the sign-in screen — nothing else about it is
told to somebody who is not signed in.
**It is a second way to the same token.** The password login mints a signed JWT
and every route, including the WebSocket, knows only that; the provider route
ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which
is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) and no
new dependency: discovery is one document, the exchange is one form post, and
checking an ID token is a signature and five claims — Node imports a JWK
directly.
**Authorization Code with PKCE, exchanged on the server.** The client secret
never reaches the browser, and neither does the session token: the callback
redirects home with a **one-time ticket**, valid for a minute and consumed on
first use, which the app swaps for its token. A token in a redirect URL would
land in the history, in the proxy's log and in the referrer of the next request.
**Accounts.** The provider's `sub` is stored on the account, and that is the
link from then on — either side may be renamed without breaking it. On a first
sign-in the subject is bound to an account of the same name if there is one, and
otherwise **a new account is made**. Never an administrator: rights are granted
in this app, and a group at the provider must not be a way around that.
That first binding trusts the provider's usernames, and it is worth being
explicit about what that means: whoever can set `preferred_username` over there
can decide which account here they land in the first time. With a provider whose
names are set by *its* administrator — PocketID, Authentik, Keycloak as they are
normally run — that is exactly the intent. Do not point this at one where people
choose their own name freely.
**Both doors stay open.** An account created by the provider has no password
here at all — the interface stops offering to change one, and the login route
refuses it as it refuses a wrong one, at the same speed. An administrator can
still *set* a password for such an account, which is deliberate: it is the way
back in the day the provider is unreachable.
Two things about the setting-up, both easy to get wrong:
- the redirect URI at the provider must be exactly
`<public url>/api/auth/oidc/callback`;
- the **server** has to reach the issuer itself — for the token exchange and the
key set — not merely your browser. Behind a reverse proxy those are different
networks.
The refresh token is deliberately not used. The provider is asked once, at
sign-in; InkNote's own token carries the session from then on, and nothing here
needs the provider to be reachable afterwards. That is what keeps the app
working offline, which is most of the point of it.
### Notebooks
| Method | Endpoint | Description |
@@ -253,6 +311,7 @@ Connect to `ws://localhost:4000/ws`, then send:
Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc.
---
## Sync Engine (Client)
@@ -458,16 +517,17 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| Suite | Checks | What it holds down |
| --- | --- | --- |
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
| `editor` | 37 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `oidc` | 26 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client |
| `editor` | 40 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `export` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
| `chapters` | 60 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `chapters` | 65 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |