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

+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 ─── */