Changeset 9e27d64 in Klonkt for src/config


Ignore:
Timestamp:
06/14/2026 07:31:23 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
6351545
Parents:
32cc601
Message:

auth: password admin + per-instance Google for listeners (no broker)

Robin's choice: every self-hoster has their own password admin account,
and can optionally let listeners log in to comment using their OWN Google
client. No central broker (that would tie every customer site to Robin's
Google Cloud -> systemic risk on abuse).

  • Admin = username/password (bcrypt). First-time setup via /auth/register (only when there are 0 users); closed afterwards. No public registration.
  • Forgot password: /auth/reset-request -> email (if SMTP configured) with reset link; CLI break-glass npm run reset-admin always works (no email needed).
  • Change password (logged in) restored in /account.
  • Google = per-instance own credentials, OPTIONAL, listeners only -> always role member, never admin (god/admin email is rejected; google_sub mismatch too).
  • config/google.js back to direct Google OAuth; config/mailer.js new (nodemailer).
  • jose removed from deps; nodemailer added.

Security review (workflow) incorporated:

  • Reset token no longer in production logs (dev only).
  • Reset link from PUBLIC_BASE_URL instead of X-Forwarded-Host (host poisoning).
  • Reset tokens stored SHA-256-hashed in the DB.
  • Same-origin check on all state-modifying POSTs (CSRF layer on top of sameSite-lax).
  • Login always runs one bcrypt comparison (no timing enumeration).

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

Location:
src/config
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/config/google.js

    r32cc601 r9e27d64  
    1 // Google-login via de centrale Klonkt-broker (license.klonkt.com).
     1// Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet
     2// z'n EIGEN Google-client in .env. Zo hangt elke site aan z'n eigen Google Cloud
     3// project — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.
    24//
    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.
     5// Optioneel: staat dit niet ingesteld, dan is er simpelweg geen "Login met
     6// Google"-knop en blijft de rest van de site werken. Google-login geeft NOOIT
     7// beheerrechten — beheer gaat via het wachtwoord-account.
    78//
    89// 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.
     10//   GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
     11//   GOOGLE_REDIRECT_URI = https://<dit-domein>/auth/google/callback
    1312
    14 import { importSPKI, jwtVerify } from 'jose';
     13const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
     14const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
     15const REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || '';
    1516
    16 const ALG = 'EdDSA';
    17 const ISSUER = 'klonkt-license';
     17const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
     18const TOKEN_URL = 'https://oauth2.googleapis.com/token';
     19const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
    1820
    19 const BROKER_URL = (process.env.KLONKT_BROKER_URL || '').replace(/\/$/, '');
    20 const SITE_ORIGIN = (process.env.SITE_ORIGIN || '').replace(/\/$/, '');
    21 
    22 export function brokerConfigured() {
    23   return !!(BROKER_URL && SITE_ORIGIN);
     21export function googleConfigured() {
     22  return !!(CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
    2423}
    2524
    26 // Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
    27 export function callbackUrl() {
    28   return `${SITE_ORIGIN}/auth/google/callback`;
     25export function authorizeUrl(state) {
     26  const p = new URLSearchParams({
     27    client_id: CLIENT_ID,
     28    redirect_uri: REDIRECT_URI,
     29    response_type: 'code',
     30    scope: 'openid email profile',
     31    state,
     32    access_type: 'online',
     33    prompt: 'select_account',
     34  });
     35  return `${AUTH_URL}?${p.toString()}`;
    2936}
    3037
    31 export function brokerStartUrl(istate) {
    32   const p = new URLSearchParams({ return: callbackUrl(), istate });
    33   return `${BROKER_URL}/auth/google/start?${p.toString()}`;
     38export async function exchangeCode(code) {
     39  const body = new URLSearchParams({
     40    code,
     41    client_id: CLIENT_ID,
     42    client_secret: CLIENT_SECRET,
     43    redirect_uri: REDIRECT_URI,
     44    grant_type: 'authorization_code',
     45  });
     46  const r = await fetch(TOKEN_URL, {
     47    method: 'POST',
     48    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
     49    body,
     50  });
     51  if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`);
     52  return r.json(); // { access_token, id_token, ... }
    3453}
    3554
    36 // Broker-pubkey ophalen + cachen (bij fout NIET permanent cachen).
    37 let _pubkeyPromise = null;
    38 function 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;
     55// Returns { sub, email, email_verified, name, picture }.
     56export async function fetchUserinfo(accessToken) {
     57  const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
     58  if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
     59  return r.json();
    5060}
    51 
    52 // Verifieer het identity-token van de broker. Returnt de payload
    53 // { typ:'identity', sub, email, name, picture, jti, exp, ... }.
    54 export 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.
    67 const _usedJti = new Map(); // jti -> exp (epoch seconds)
    68 export 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 }
Note: See TracChangeset for help on using the changeset viewer.