Changeset 32cc601 in Klonkt for src/config


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/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}
Note: See TracChangeset for help on using the changeset viewer.