| 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 |
|
|---|
| 14 | import { importSPKI, jwtVerify } from 'jose';
|
|---|
| 15 |
|
|---|
| 16 | const ALG = 'EdDSA';
|
|---|
| 17 | const ISSUER = 'klonkt-license';
|
|---|
| 18 |
|
|---|
| 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);
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | // Waar de broker naartoe terugstuurt (moet in de broker-allowlist staan).
|
|---|
| 27 | export function callbackUrl() {
|
|---|
| 28 | return `${SITE_ORIGIN}/auth/google/callback`;
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | export 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).
|
|---|
| 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;
|
|---|
| 50 | }
|
|---|
| 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 | }
|
|---|