longcelot-sheet-db
Version:
Google Sheets-backed staging database adapter for Node.js with schema-first design
194 lines • 8.48 kB
JavaScript
;
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