UNPKG

longcelot-sheet-db

Version:

Google Sheets-backed staging database adapter for Node.js with schema-first design

194 lines 8.48 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.verifyJwt = verifyJwt; exports.createAuthRouter = createAuthRouter; const oauth_1 = require("./oauth"); const DEFAULT_JWT_EXPIRES_IN_SECONDS = 60 * 60 * 24; // 1 day const DEFAULT_STATE_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes — long enough to complete a Google consent screen function signJwt(payload, secret, expiresInSeconds) { // Minimal HS256 JWT without a third-party dep — uses Node's built-in crypto const crypto = require('crypto'); const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const iat = Math.floor(Date.now() / 1000); const body = Buffer.from(JSON.stringify({ ...payload, iat, exp: iat + expiresInSeconds })).toString('base64url'); const sig = crypto .createHmac('sha256', secret) .update(`${header}.${body}`) .digest('base64url'); return `${header}.${body}.${sig}`; } /** * Verifies a token issued by `createAuthRouter`'s callback — checks the HS256 signature * (constant-time comparison) and rejects an expired or malformed token. Returns the decoded * payload (including `iat`/`exp`) on success, `null` otherwise. Exported because until this * existed, downstream apps had no package-supported way to verify the JWT lsdb issues and were * left to reimplement HS256 verification themselves. */ function verifyJwt(token, secret) { const crypto = require('crypto'); const parts = token.split('.'); if (parts.length !== 3) return null; const [header, body, sig] = parts; const expectedSig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url'); const sigBuf = Buffer.from(sig); const expectedBuf = Buffer.from(expectedSig); if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) { return null; } let payload; try { payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf-8')); } catch { return null; } const exp = payload.exp; if (typeof exp === 'number' && Math.floor(Date.now() / 1000) >= exp) return null; return payload; } /** * Self-contained, unpredictable OAuth `state` value — HMAC-signed rather than server-session-backed * so it fits this router's stateless design (no session store dependency). Round-tripped through * Google unmodified and re-verified in the callback (`verifyState`) as CSRF protection for the * login flow: without it, an attacker can start their own OAuth transaction and trick a victim's * browser into completing it under the attacker's identity. */ function signState(secret) { const crypto = require('crypto'); const nonce = crypto.randomBytes(16).toString('base64url'); const ts = Date.now().toString(); const payload = `${nonce}.${ts}`; const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); return `${payload}.${sig}`; } function verifyState(state, secret, maxAgeMs) { if (!state) return false; const parts = state.split('.'); if (parts.length !== 3) return false; const [nonce, ts, sig] = parts; const crypto = require('crypto'); const expectedSig = crypto.createHmac('sha256', secret).update(`${nonce}.${ts}`).digest('base64url'); const sigBuf = Buffer.from(sig); const expectedBuf = Buffer.from(expectedSig); if (sigBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(sigBuf, expectedBuf)) { return false; } const issuedAt = Number(ts); if (!Number.isFinite(issuedAt)) return false; return Date.now() - issuedAt <= maxAgeMs; } /** * Creates an Express-compatible auth router that wires up two routes: * * GET {basePath}/auth/google — redirects to Google OAuth consent screen * GET {basePath}/auth/callback — exchanges code, verifies identity, issues JWT * * @example * ```typescript * import express from 'express' * import { createAuthRouter } from 'longcelot-sheet-db' * * const auth = createAuthRouter({ * adapter, * jwtSecret: process.env.JWT_SECRET!, * frontendUrl: process.env.FRONTEND_URL!, * registrationPolicy: 'login-only', // admin-only — no self-signup * async onUser(profile, adapter) { * const ctx = adapter.withContext({ userId: 'auth', actor: 'admin', actorSheetId: process.env.ADMIN_SHEET_ID! }) * return await ctx.table('users').findOne({ where: { email: profile.email } }) * }, * }) * * app.use(auth.handler) * ``` */ function createAuthRouter(options) { const { adapter, jwtSecret, frontendUrl, onUser, registrationPolicy = 'open', basePath = '', jwtExpiresInSeconds = DEFAULT_JWT_EXPIRES_IN_SECONDS, tokenDelivery = 'query', } = options; const oauthCfg = options.oauthConfig ?? { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectUri: process.env.GOOGLE_REDIRECT_URI, }; const oauth = (0, oauth_1.createLoginOAuthManager)(oauthCfg); const loginPath = `${basePath}/auth/google`; const callbackPath = `${basePath}/auth/callback`; const handler = async (req, res, next) => { const url = req.path ?? req.url ?? ''; const pathname = url.split('?')[0]; if (pathname === loginPath) { const state = signState(jwtSecret); const authUrl = oauth.getAuthUrl(undefined, state); res.redirect(authUrl); return; } if (pathname === callbackPath) { if (!verifyState(req.query['state'], jwtSecret, DEFAULT_STATE_MAX_AGE_MS)) { res.status(401).json({ error: 'Invalid or expired OAuth state — possible CSRF attempt, restart login' }); return; } const code = req.query['code']; if (!code) { res.status(400).json({ error: 'Missing OAuth code' }); return; } let tokens; try { tokens = (await oauth.getTokens(code)); } catch { res.status(401).json({ error: 'Failed to exchange OAuth code' }); return; } let profile; try { const idToken = tokens['id_token']; if (!idToken) throw new Error('No id_token in OAuth response — ensure openid scope is requested'); profile = (await oauth.verifyToken(idToken)); } catch (err) { // Log the real cause server-side only — echoing a caught exception's message back into // the HTTP response is an information-disclosure risk (CWE-209): onUser/token verification // can throw errors carrying internal detail (a DB error, a Sheets API error, a stack-trace // string), and the recipient here is the end user's own browser completing the OAuth // redirect, not a trusted operator. console.error('[lsdb auth] Token verification failed:', err); res.status(401).json({ error: 'Token verification failed' }); return; } let user; try { user = await onUser(profile, adapter); } catch (err) { console.error('[lsdb auth] onUser callback threw:', err); res.status(500).json({ error: 'Authentication failed' }); return; } if (user === null) { if (registrationPolicy === 'login-only') { res .status(401) .json({ error: `Access denied: '${profile.email}' is not an authorised user. Contact an admin.` }); return; } // 'open' policy — fall through with minimal profile as user payload user = { email: profile.email, name: profile.name, sub: profile.sub }; } const jwt = signJwt(user, jwtSecret, jwtExpiresInSeconds); const separator = tokenDelivery === 'fragment' ? '#' : '?'; res.redirect(`${frontendUrl}${separator}token=${jwt}`); return; } next(); }; return { handler, loginPath, callbackPath }; } //# sourceMappingURL=router.js.map