Changeset 32cc601 in Klonkt for src/routes/auth.js


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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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.