Changeset 32cc601 in Klonkt


Ignore:
Timestamp:
06/14/2026 06:55:51 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
9e27d64
Parents:
ae924a2
Message:

auth: route Google login via central Klonkt broker (no credentials per instance)

The instance no longer talks directly to Google but delegates to the central
broker (license.klonkt.com): it performs the OAuth dance with one Google client
and returns a signed, audience-bound identity token, which we verify offline
against the broker's pubkey. This way no self-hoster needs to create their own
Google client.

  • config/google.js: broker client (brokerStartUrl + verifyIdentityToken against /pubkey: issuer + EdDSA + audience==SITE_ORIGIN + typ; consumeJti against replay).
  • routes/auth.js: /auth/google sets state in session -> broker; callback checks state (CSRF) + token + replay, finds-or-creates user on email.
  • god only via ADMIN_EMAIL; the "first user becomes god" bootstrap only applies when NO ADMIN_EMAIL is set (otherwise a stranger on a fresh install could become owner).
  • Reject login if the email address is already linked to a different google_sub.
  • jose added to dependencies (verifier; otherwise npm ci crashes the app).
  • .env: KLONKT_BROKER_URL + SITE_ORIGIN instead of GOOGLE_CLIENT_ID/SECRET.

Co-Authored-By: Claude <noreply@…>

Files:
5 edited

Legend:

Unmodified
Added
Removed
  • .env.example

    rae924a2 r32cc601  
    55MEDIA_PATH=./storage/media
    66
    7 # ── Google login (OAuth2) ──────────────────────────────────────────
    8 # Inloggen gaat volledig via Google. Maak een OAuth-client aan in de
    9 # Google Cloud Console (APIs & Services -> Credentials -> OAuth client ID,
    10 # type "Web application") en vul hieronder in. Zonder deze waarden boot de
    11 # app gewoon, maar toont de loginpagina "nog niet geconfigureerd".
    12 #
    13 # GOOGLE_REDIRECT_URI moet EXACT overeenkomen met een "Authorized redirect
    14 # URI" in de Google-client, en is per site het eigen domein + /auth/google/callback.
    15 GOOGLE_CLIENT_ID=
    16 GOOGLE_CLIENT_SECRET=
    17 GOOGLE_REDIRECT_URI=https://example.com/auth/google/callback
     7# ── Google login via de Klonkt-broker ──────────────────────────────
     8# Inloggen gaat via Google, maar gerouteerd door de centrale Klonkt-broker —
     9# deze app maakt dus GEEN eigen Google-client aan. Je hoeft hier alleen de
     10# broker-URL en het eigen publieke origin te zetten. Zonder deze waarden boot
     11# de app gewoon en toont de loginpagina "nog niet geconfigureerd".
     12KLONKT_BROKER_URL=https://license.klonkt.com
     13# Het eigen publieke origin (scheme + host, geen pad/slash). Bepaalt de callback
     14# (<SITE_ORIGIN>/auth/google/callback) en de audience die we eisen. Dit origin
     15# moet in de KLONKT_ALLOWED_ORIGINS van de broker staan.
     16SITE_ORIGIN=https://example.com
    1817
    1918# Het Google-e-mailadres dat owner/admin (god) is op deze instance.
  • package-lock.json

    rae924a2 r32cc601  
    2121        "helmet": "^7.1.0",
    2222        "htmx.org": "1.9.12",
     23        "jose": "^5.10.0",
    2324        "marked": "^11.1.1",
    2425        "multer": "^1.4.5-lts.1",
     
    11651166      }
    11661167    },
     1168    "node_modules/jose": {
     1169      "version": "5.10.0",
     1170      "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
     1171      "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
     1172      "license": "MIT",
     1173      "funding": {
     1174        "url": "https://github.com/sponsors/panva"
     1175      }
     1176    },
    11671177    "node_modules/marked": {
    11681178      "version": "11.2.0",
  • package.json

    rae924a2 r32cc601  
    2323    "helmet": "^7.1.0",
    2424    "htmx.org": "1.9.12",
     25    "jose": "^5.10.0",
    2526    "marked": "^11.1.1",
    2627    "multer": "^1.4.5-lts.1",
  • src/config/google.js

    rae924a2 r32cc601  
    1 // Google OAuth2 (per-instance). Raw via de ingebouwde fetch — geen passport-dep.
    2 // Config via env (per instance, in .env):
    3 //   GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
    4 //   GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback
    5 //   ADMIN_EMAIL         = de Google-mail die owner/admin (god) is op deze instance
    6 // Niet geconfigureerd? Dan booten we gewoon door; /auth/google meldt netjes
    7 // "nog niet geconfigureerd" i.p.v. te crashen.
     1// Google-login via de centrale Klonkt-broker (license.klonkt.com).
     2//
     3// Deze instance praat NOOIT zelf met Google. De broker doet de OAuth-dans met één
     4// centrale Google-client en stuurt een kortlevend, gesigneerd identity-token terug;
     5// dat verifiëren we offline tegen de broker-pubkey. Zo hoeft geen enkele self-host
     6// een eigen Google-client aan te maken.
     7//
     8// Config via env:
     9//   KLONKT_BROKER_URL = https://license.klonkt.com
     10//   SITE_ORIGIN       = het eigen publieke origin (bv https://roboburr.com) —
     11//                       bepaalt de callback + de audience die we eisen.
     12//   ADMIN_EMAIL       = de Google-mail die owner/admin (god) is op deze instance.
    813
    9 const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
    10 const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
    11 const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
     14import { importSPKI, jwtVerify } from 'jose';
    1215
    13 const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
    14 const TOKEN_URL = 'https://oauth2.googleapis.com/token';
    15 const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
     16const ALG = 'EdDSA';
     17const ISSUER = 'klonkt-license';
    1618
    17 export function googleConfigured() {
    18   return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
     19const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, '');
     20const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, '');
     21
     22export function brokerConfigured() {
     23  return !!(BROKER_URL && SITE_ORIGIN);
    1924}
    2025
    21 export function authorizeUrl(state) {
    22   const p = new URLSearchParams({
    23     client_id: CLIENT_ID,
    24     redirect_uri: REDIRECT_URI,
    25     response_type: 'code',
    26     scope: 'openid email profile',
    27     state,
    28     access_type: 'online',
    29     prompt: 'select_account',
    30   });
    31   return `${AUTH_URL}?${p.toString()}`;
     26// Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
     27export function callbackUrl() {
     28  return `${SITE_ORIGIN}/auth/google/callback`;
    3229}
    3330
    34 export async function exchangeCode(code) {
    35   const body = new URLSearchParams({
    36     code,
    37     client_id: CLIENT_ID,
    38     client_secret: CLIENT_SECRET,
    39     redirect_uri: REDIRECT_URI,
    40     grant_type: 'authorization_code',
    41   });
    42   const r = await fetch(TOKEN_URL, {
    43     method: 'POST',
    44     headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    45     body,
    46   });
    47   if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status} ${await r.text().catch(() => '')}`);
    48   return r.json(); // { access_token, id_token, ... }
     31export function brokerStartUrl(istate) {
     32  const p = new URLSearchParams({ return: callbackUrl(), istate });
     33  return `${BROKER_URL}/auth/google/start?${p.toString()}`;
    4934}
    5035
    51 // Returns { sub, email, email_verified, name, picture }.
    52 export async function fetchUserinfo(accessToken) {
    53   const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
    54   if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
    55   return r.json();
     36// Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen).
     37let _pubkeyPromise = null;
     38function getPublicKey() {
     39  if (!_pubkeyPromise) {
     40    _pubkeyPromise = (async () => {
     41      const r = await fetch(`${BROKER_URL}/pubkey`);
     42      if (!r.ok) throw new Error(`broker /pubkey faalde: ${r.status}`);
     43      return importSPKI(await r.text(), ALG);
     44    })().catch((e) => {
     45      _pubkeyPromise = null;
     46      throw e;
     47    });
     48  }
     49  return _pubkeyPromise;
    5650}
     51
     52// Verifieer het identity-token van de broker. Returnt de payload
     53// { typ:'identity', sub, email, name, picture, jti, exp, ... }.
     54export async function verifyIdentityToken(token) {
     55  const key = await getPublicKey();
     56  const { payload } = await jwtVerify(token, key, {
     57    issuer: ISSUER,
     58    algorithms: [ALG],
     59    audience: SITE_ORIGIN, // token moet voor ÓNZE site bedoeld zijn
     60  });
     61  if (payload.typ !== 'identity') throw new Error('verkeerd tokentype');
     62  return payload;
     63}
     64
     65// Kleine in-memory jti-cache tegen replay binnen de (korte) geldigheidsduur.
     66// Returnt false als de jti al gebruikt is.
     67const _usedJti = new Map(); // jti -> exp (epoch seconds)
     68export function consumeJti(jti, expEpoch) {
     69  if (!jti) return true; // geen jti = niets te dedupen
     70  const now = Math.floor(Date.now() / 1000);
     71  for (const [k, e] of _usedJti) if (e < now) _usedJti.delete(k);
     72  if (_usedJti.has(jti)) return false;
     73  _usedJti.set(jti, expEpoch || now + 600);
     74  return true;
     75}
  • src/routes/auth.js

    rae924a2 r32cc601  
    55import { renderPage } from '../middleware/render.js';
    66import { safeNext } from '../middleware/auth.js';
    7 import { googleConfigured, authorizeUrl, exchangeCode, fetchUserinfo } from '../config/google.js';
     7import { brokerConfigured, brokerStartUrl, verifyIdentityToken, consumeJti } from '../config/google.js';
    88
    99const router = express.Router();
    1010const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
    1111
    12 // ==================== LOGIN (alleen Google) ====================
    13 // Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google.
     12// ==================== LOGIN (Google via Klonkt-broker) ====================
     13// Het oude username/wachtwoord-systeem is verwijderd; inloggen gaat via Google,
     14// gerouteerd door de centrale broker (geen Google-creds op deze instance).
    1415router.get('/login', (req, res) => {
    1516  const next = safeNext(req.query.next) || '';
     
    1819    pageTitle: 'Inloggen',
    1920    bodyClass: 'on-special',
    20     googleReady: googleConfigured(),
     21    googleReady: brokerConfigured(),
    2122    error: req.query.error || null,
    2223    next,
     
    2425});
    2526
    26 // Start de Google OAuth-flow.
     27// Start de login: random state in de sessie (CSRF), dan door naar de broker.
    2728router.get('/google', (req, res) => {
    28   if (!googleConfigured()) {
     29  if (!brokerConfigured()) {
    2930    return res.redirect('/auth/login?error=' + encodeURIComponent('Google-login is op deze site nog niet geconfigureerd.'));
    3031  }
    31   const next = safeNext(req.query.next) || '';
    32   const state = crypto.randomBytes(16).toString('hex');
    33   req.session.oauthState = state;
    34   req.session.oauthNext = next;
    35   res.redirect(authorizeUrl(state));
     32  const istate = crypto.randomBytes(16).toString('hex');
     33  req.session.loginState = istate;
     34  req.session.loginNext = safeNext(req.query.next) || '';
     35  res.redirect(brokerStartUrl(istate));
    3636});
    3737
     
    4848}
    4949
    50 // Google callback: wissel code in, haal profiel, vind-of-maak user (op e-mail),
    51 // zet de sessie. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
     50// Callback van de broker: ?klonkt_id_token=&klonkt_state=(&klonkt_login_error=).
     51// We checken state (CSRF), verifiëren het token (sig + audience + replay), en
     52// vinden-of-maken de user op e-mail. ADMIN_EMAIL bepaalt wie owner/admin (god) is.
    5253router.get('/google/callback', async (req, res) => {
    5354  const fail = (msg) => res.redirect('/auth/login?error=' + encodeURIComponent(msg));
    5455  try {
    55     const { code, state } = req.query;
    56     if (!code || !state || state !== req.session.oauthState) {
    57       return fail('Login afgebroken of ongeldige sessie.');
     56    const { klonkt_id_token: idToken, klonkt_state: state, klonkt_login_error: loginError } = req.query;
     57
     58    // State-check (CSRF) eerst — bind de callback aan de sessie die login startte.
     59    const expected = req.session.loginState;
     60    const next = safeNext(req.session.loginNext) || '';
     61    delete req.session.loginState;
     62    delete req.session.loginNext;
     63    if (!expected || state !== expected) return fail('Login afgebroken of ongeldige sessie.');
     64
     65    if (loginError === 'unverified_email') return fail('Geen geverifieerd Google-e-mailadres.');
     66    if (!idToken) return fail('Geen logintoken ontvangen.');
     67
     68    let payload;
     69    try {
     70      payload = await verifyIdentityToken(String(idToken));
     71    } catch (e) {
     72      console.error('[auth/google/callback] tokenverificatie:', e.message);
     73      return fail('Logintoken ongeldig of verlopen.');
    5874    }
    59     const next = safeNext(req.session.oauthNext) || '';
    60     delete req.session.oauthState;
    61     delete req.session.oauthNext;
     75    if (!consumeJti(payload.jti, payload.exp)) return fail('Logintoken al gebruikt.');
    6276
    63     const tok = await exchangeCode(String(code));
    64     const info = await fetchUserinfo(tok.access_token);
    65     const email = (info.email || '').trim().toLowerCase();
    66     if (!email || info.email_verified === false) {
    67       return fail('Geen geverifieerd Google-e-mailadres ontvangen.');
    68     }
     77    const email = (payload.email || '').trim().toLowerCase();
     78    if (!email) return fail('Geen e-mailadres in logintoken.');
    6979
    7080    const isAdmin = !!ADMIN_EMAIL && email === ADMIN_EMAIL;
     
    7383    if (!user) {
    7484      const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
    75       const role = (isAdmin || userCount === 0) ? 'god' : 'member';
     85      // god = de ingestelde ADMIN_EMAIL. De "eerste user wordt god"-bootstrap geldt
     86      // ALLEEN als er helemaal geen ADMIN_EMAIL is ingesteld — anders zou een vreemde
     87      // die toevallig als eerste inlogt op een verse install eigenaar worden.
     88      const role = isAdmin ? 'god' : (!ADMIN_EMAIL && userCount === 0 ? 'god' : 'member');
    7689      const userId = uuid();
    77       const username = uniqueUsername(info.name || email.split('@')[0]);
    78       // password_hash is NOT NULL in het schema; we zetten een onbruikbare
    79       // sentinel (er is geen wachtwoord-login meer).
     90      const username = uniqueUsername(payload.name || email.split('@')[0]);
     91      // password_hash is NOT NULL in het schema; er is geen wachtwoord-login meer,
     92      // dus we zetten een onbruikbare sentinel.
    8093      db.prepare(`
    8194        INSERT INTO users (id, username, email, password_hash, role, avatar_url, theme, palette, google_sub)
    8295        VALUES (?, ?, ?, '!google-oauth', ?, ?, 'dark', 'sage', ?)
    83       `).run(userId, username, info.email || email, role, info.picture || null, info.sub || null);
     96      `).run(userId, username, payload.email || email, role, payload.picture || null, payload.sub || null);
    8497
    8598      // Eerste/admin-user krijgt een persoonlijke site (zoals de oude flow), maar
     
    95108      user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
    96109    } else {
     110      // Identiteits-integriteit: als deze user al aan een ander Google-account
     111      // gekoppeld is (google_sub mismatch), weiger — voorkomt overname als een
     112      // geverifieerd e-mailadres ooit naar een andere Google-sub verhuist.
     113      if (user.google_sub && payload.sub && user.google_sub !== payload.sub) {
     114        return fail('Dit e-mailadres is al aan een ander Google-account gekoppeld.');
     115      }
    97116      // Bestaande user (gekoppeld op e-mail): koppel Google-id + avatar als die
    98117      // nog ontbreken, en promoveer naar god als dit ADMIN_EMAIL is.
     
    103122            updated_at = CURRENT_TIMESTAMP
    104123        WHERE id = ?
    105       `).run(info.sub || null, info.picture || null, user.id);
     124      `).run(payload.sub || null, payload.picture || null, user.id);
    106125      if (isAdmin && user.role !== 'god') {
    107126        db.prepare("UPDATE users SET role = 'god' WHERE id = ?").run(user.id);
Note: See TracChangeset for help on using the changeset viewer.