115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
import crypto from 'crypto';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import { resolveSession, type RokcetUserRecord, type RokcetWorkspaceRecord } from './services/rokcetDb';
|
|
|
|
export type RokcetAuthContext = {
|
|
user: RokcetUserRecord & { isDemo?: boolean };
|
|
workspaces: RokcetWorkspaceRecord[];
|
|
isDemo: boolean;
|
|
};
|
|
|
|
const isSecure = process.env.NODE_ENV === 'production';
|
|
export const SESSION_COOKIE = isSecure ? '__Host-rokcet_session' : 'rokcet_session';
|
|
export const SSO_STATE_COOKIE = isSecure ? '__Host-rokcet_sso_state' : 'rokcet_sso_state';
|
|
const DEMO_WORKSPACE: RokcetWorkspaceRecord = {
|
|
id: 'demo-workspace',
|
|
name: 'Rokcet Demo Workspace',
|
|
description: 'Isolated demo workspace. Demo access never becomes a real Buckets developer account.',
|
|
ownerId: 'demo-user',
|
|
createdAt: '2026-08-15T00:00:00.000Z',
|
|
};
|
|
|
|
const ephemeralSecret = crypto.randomBytes(32).toString('hex');
|
|
function sessionSecret() {
|
|
const configured = process.env.ROKCET_SESSION_SECRET?.trim();
|
|
if (!configured && isSecure) throw new Error('ROKCET_SESSION_SECRET is required in production');
|
|
return configured || ephemeralSecret;
|
|
}
|
|
|
|
export function parseCookies(req: Request): Record<string, string> {
|
|
const header = req.headers.cookie || '';
|
|
return header.split(';').reduce<Record<string, string>>((acc, item) => {
|
|
const idx = item.indexOf('=');
|
|
if (idx < 0) return acc;
|
|
const key = item.slice(0, idx).trim();
|
|
const value = item.slice(idx + 1).trim();
|
|
if (key) acc[key] = decodeURIComponent(value);
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
export function setSessionCookie(res: Response, token: string, maxAgeSeconds = 30 * 86400) {
|
|
res.cookie(SESSION_COOKIE, token, {
|
|
httpOnly: true,
|
|
secure: isSecure,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: maxAgeSeconds * 1000,
|
|
});
|
|
}
|
|
|
|
export function clearSessionCookie(res: Response) {
|
|
res.clearCookie(SESSION_COOKIE, { httpOnly: true, secure: isSecure, sameSite: 'lax', path: '/' });
|
|
}
|
|
|
|
export function setSsoStateCookie(res: Response, state: string) {
|
|
res.cookie(SSO_STATE_COOKIE, state, {
|
|
httpOnly: true,
|
|
secure: isSecure,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
maxAge: 10 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
export function clearSsoStateCookie(res: Response) {
|
|
res.clearCookie(SSO_STATE_COOKIE, { httpOnly: true, secure: isSecure, sameSite: 'lax', path: '/' });
|
|
}
|
|
|
|
function demoSignature(expires: string) {
|
|
return crypto.createHmac('sha256', sessionSecret()).update(`demo:${expires}`).digest('base64url');
|
|
}
|
|
|
|
export function newDemoSessionToken() {
|
|
const expires = String(Date.now() + 8 * 60 * 60 * 1000);
|
|
return `demo.${expires}.${demoSignature(expires)}`;
|
|
}
|
|
|
|
function demoContext(token: string): RokcetAuthContext | null {
|
|
const [prefix, expires, signature] = token.split('.');
|
|
if (prefix !== 'demo' || !expires || !signature || Number(expires) <= Date.now()) return null;
|
|
const expected = demoSignature(expires);
|
|
if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;
|
|
return {
|
|
user: {
|
|
id: 'demo-user',
|
|
bucketsUserId: '',
|
|
email: 'demo@rokcet.dev',
|
|
name: 'Demo Developer',
|
|
createdAt: '2026-08-15T00:00:00.000Z',
|
|
isDemo: true,
|
|
},
|
|
workspaces: [DEMO_WORKSPACE],
|
|
isDemo: true,
|
|
};
|
|
}
|
|
|
|
export function getRokcetAuth(req: Request): RokcetAuthContext | null {
|
|
const token = parseCookies(req)[SESSION_COOKIE];
|
|
if (!token) return null;
|
|
if (token.startsWith('demo.')) return demoContext(token);
|
|
const resolved = resolveSession(token);
|
|
return resolved ? { ...resolved, isDemo: false } : null;
|
|
}
|
|
|
|
export function requireRokcetAuth(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const auth = getRokcetAuth(req);
|
|
if (!auth) return res.status(401).json({ success: false, code: 'AUTH_REQUIRED', error: 'Rokcet authentication required' });
|
|
(req as any).rokcetAuth = auth;
|
|
next();
|
|
} catch (error: any) {
|
|
res.status(500).json({ success: false, code: 'AUTH_CONFIGURATION_ERROR', error: error?.message || 'Authentication configuration error' });
|
|
}
|
|
}
|