Files
rokcet-dev/server/auth.ts
2026-08-15 03:47:29 +07:00

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' });
}
}