source: Klonkt/src/config/google.js@ 32cc601

main
Last change on this file since 32cc601 was 32cc601, checked in by roboburr <roboburr@…>, 3 months ago

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

  • Property mode set to 100644
File size: 2.7 KB
Line 
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.
13
14import { importSPKI, jwtVerify } from 'jose';
15
16const ALG = 'EdDSA';
17const ISSUER = 'klonkt-license';
18
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);
24}
25
26// Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
27export function callbackUrl() {
28 return `${SITE_ORIGIN}/auth/google/callback`;
29}
30
31export function brokerStartUrl(istate) {
32 const p = new URLSearchParams({ return: callbackUrl(), istate });
33 return `${BROKER_URL}/auth/google/start?${p.toString()}`;
34}
35
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;
50}
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 TracBrowser for help on using the repository browser.