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:
1 parent
c86c63922a
commit
43a98b0972
17 files changed
+1271
-44
No files matched your search
+330
@@ -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
@@ -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 ─── */
|
||||
|
||||
Reference in new issue
Block a user