|
|
|
@@ -0,0 +1,270 @@
|
|
|
|
|
import { createHash, createHmac, createPublicKey, createVerify, timingSafeEqual } from 'node:crypto';
|
|
|
|
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
|
|
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
|
|
|
import { APP_ROOT } from './app-root.js';
|
|
|
|
|
import { actorFromVerifiedClaims, type VerifiedClaimInput } from './auth-context.js';
|
|
|
|
|
|
|
|
|
|
export type AuthMode = 'local' | 'jwt';
|
|
|
|
|
export type JwtAlgorithm = 'RS256' | 'HS256';
|
|
|
|
|
|
|
|
|
|
export interface ControlPlaneAuthConfig {
|
|
|
|
|
mode: AuthMode;
|
|
|
|
|
profile: string;
|
|
|
|
|
bind: string;
|
|
|
|
|
issuer?: string;
|
|
|
|
|
audience?: string;
|
|
|
|
|
algorithm?: JwtAlgorithm;
|
|
|
|
|
publicKey?: string;
|
|
|
|
|
hmacSecret?: string;
|
|
|
|
|
clockSkewSeconds: number;
|
|
|
|
|
roleClaim: string;
|
|
|
|
|
tenantClaim: string;
|
|
|
|
|
projectClaim: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface VerifiedClaims {
|
|
|
|
|
subject: string;
|
|
|
|
|
issuer: string;
|
|
|
|
|
audience: string[];
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
issuedAt?: number;
|
|
|
|
|
roles: string[];
|
|
|
|
|
tenant: string;
|
|
|
|
|
project: string;
|
|
|
|
|
authenticationMethod: 'jwt';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuthenticationDecision {
|
|
|
|
|
allowed: boolean;
|
|
|
|
|
reasonCode: string;
|
|
|
|
|
claims?: VerifiedClaims;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AuthProvider {
|
|
|
|
|
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds?: number): AuthenticationDecision;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface JwtHeader {
|
|
|
|
|
alg?: string;
|
|
|
|
|
typ?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type JwtPayload = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
const IDENTITY_HEADERS = [
|
|
|
|
|
'x-casan-actor', 'x-casan-role', 'x-casan-groups', 'x-casan-project', 'x-casan-tenant',
|
|
|
|
|
'x-auth-request-user', 'x-auth-request-groups', 'x-forwarded-user', 'x-forwarded-groups',
|
|
|
|
|
'x-casan-identity-verified', 'x-casan-identity-issuer',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function firstHeader(value: string | string[] | undefined): string | undefined {
|
|
|
|
|
return Array.isArray(value) ? value[0] : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeSegment<T>(segment: string): T {
|
|
|
|
|
const decoded = Buffer.from(segment, 'base64url').toString('utf8');
|
|
|
|
|
const payload: unknown = JSON.parse(decoded);
|
|
|
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('jwt_segment_not_object');
|
|
|
|
|
return payload as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringClaim(value: unknown): string | undefined {
|
|
|
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringListClaim(value: unknown): string[] {
|
|
|
|
|
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
|
|
|
|
if (typeof value === 'string') return value.split(/[\s,]+/).filter(Boolean);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function audienceClaim(value: unknown): string[] {
|
|
|
|
|
return typeof value === 'string' ? [value] : stringListClaim(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numericClaim(value: unknown): number | undefined {
|
|
|
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeScope(value: string | undefined, fallback: string): string {
|
|
|
|
|
return value && /^[A-Za-z0-9._-]+$/.test(value) ? value : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function verifySignature(input: string, signature: Buffer, config: ControlPlaneAuthConfig): boolean {
|
|
|
|
|
if (config.algorithm === 'RS256' && config.publicKey) {
|
|
|
|
|
const verifier = createVerify('RSA-SHA256');
|
|
|
|
|
verifier.update(input);
|
|
|
|
|
verifier.end();
|
|
|
|
|
return verifier.verify(config.publicKey, signature);
|
|
|
|
|
}
|
|
|
|
|
if (config.algorithm === 'HS256' && config.hmacSecret) {
|
|
|
|
|
const expected = createHmac('sha256', config.hmacSecret).update(input).digest();
|
|
|
|
|
return expected.length === signature.length && timingSafeEqual(expected, signature);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class JwtAuthProvider implements AuthProvider {
|
|
|
|
|
constructor(private readonly config: ControlPlaneAuthConfig) {}
|
|
|
|
|
|
|
|
|
|
authenticate(headers: Record<string, string | string[] | undefined>, nowSeconds = Math.floor(Date.now() / 1000)): AuthenticationDecision {
|
|
|
|
|
const authorization = firstHeader(headers.authorization);
|
|
|
|
|
if (!authorization?.startsWith('Bearer ')) return { allowed: false, reasonCode: 'auth_bearer_token_required' };
|
|
|
|
|
const token = authorization.slice('Bearer '.length).trim();
|
|
|
|
|
const parts = token.split('.');
|
|
|
|
|
if (parts.length !== 3 || parts.some((part) => !part)) return { allowed: false, reasonCode: 'auth_token_malformed' };
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const header = decodeSegment<JwtHeader>(parts[0]);
|
|
|
|
|
const payload = decodeSegment<JwtPayload>(parts[1]);
|
|
|
|
|
if (header.alg !== this.config.algorithm) return { allowed: false, reasonCode: 'auth_algorithm_mismatch' };
|
|
|
|
|
if (!verifySignature(`${parts[0]}.${parts[1]}`, Buffer.from(parts[2], 'base64url'), this.config)) {
|
|
|
|
|
return { allowed: false, reasonCode: 'auth_signature_invalid' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issuer = stringClaim(payload.iss);
|
|
|
|
|
const audience = audienceClaim(payload.aud);
|
|
|
|
|
const subject = stringClaim(payload.sub);
|
|
|
|
|
const expiresAt = numericClaim(payload.exp);
|
|
|
|
|
const notBefore = numericClaim(payload.nbf);
|
|
|
|
|
const issuedAt = numericClaim(payload.iat);
|
|
|
|
|
const skew = this.config.clockSkewSeconds;
|
|
|
|
|
if (!issuer || issuer !== this.config.issuer) return { allowed: false, reasonCode: 'auth_issuer_invalid' };
|
|
|
|
|
if (!this.config.audience || !audience.includes(this.config.audience)) return { allowed: false, reasonCode: 'auth_audience_invalid' };
|
|
|
|
|
if (!subject) return { allowed: false, reasonCode: 'auth_subject_required' };
|
|
|
|
|
if (!expiresAt || nowSeconds - skew >= expiresAt) return { allowed: false, reasonCode: 'auth_token_expired' };
|
|
|
|
|
if (notBefore !== undefined && nowSeconds + skew < notBefore) return { allowed: false, reasonCode: 'auth_token_not_yet_valid' };
|
|
|
|
|
if (issuedAt !== undefined && issuedAt > nowSeconds + skew) return { allowed: false, reasonCode: 'auth_issued_at_invalid' };
|
|
|
|
|
|
|
|
|
|
const roles = stringListClaim(payload[this.config.roleClaim]);
|
|
|
|
|
const rawTenant = stringClaim(payload[this.config.tenantClaim]);
|
|
|
|
|
const rawProject = stringClaim(payload[this.config.projectClaim]);
|
|
|
|
|
if ((rawTenant && safeScope(rawTenant, '') === '') || (rawProject && safeScope(rawProject, '') === '')) {
|
|
|
|
|
return { allowed: false, reasonCode: 'auth_scope_invalid' };
|
|
|
|
|
}
|
|
|
|
|
const tenant = safeScope(rawTenant, 'default');
|
|
|
|
|
const project = safeScope(rawProject, 'default');
|
|
|
|
|
return {
|
|
|
|
|
allowed: true,
|
|
|
|
|
reasonCode: 'auth_verified',
|
|
|
|
|
claims: { subject, issuer, audience, expiresAt, issuedAt, roles, tenant, project, authenticationMethod: 'jwt' },
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return { allowed: false, reasonCode: 'auth_token_malformed' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function loadAuthConfig(env: NodeJS.ProcessEnv = process.env): ControlPlaneAuthConfig {
|
|
|
|
|
const profile = env.CASAN_CP_STRICT === '1' ? 'strict' : (env.CASAN_PROFILE || 'development').toLowerCase();
|
|
|
|
|
const mode = (env.CASAN_CP_AUTH_MODE || 'local').toLowerCase() as AuthMode;
|
|
|
|
|
const bind = env.CP_BIND || '127.0.0.1';
|
|
|
|
|
const publicKeyPath = env.CASAN_CP_JWT_PUBLIC_KEY_FILE;
|
|
|
|
|
const publicKey = publicKeyPath && existsSync(publicKeyPath) ? readFileSync(publicKeyPath, 'utf8') : undefined;
|
|
|
|
|
const skew = Number(env.CASAN_CP_JWT_CLOCK_SKEW_SECONDS ?? 60);
|
|
|
|
|
return {
|
|
|
|
|
mode,
|
|
|
|
|
profile,
|
|
|
|
|
bind,
|
|
|
|
|
issuer: env.CASAN_CP_JWT_ISSUER,
|
|
|
|
|
audience: env.CASAN_CP_JWT_AUDIENCE,
|
|
|
|
|
algorithm: publicKey ? 'RS256' : env.CASAN_CP_JWT_HS256_SECRET ? 'HS256' : undefined,
|
|
|
|
|
publicKey,
|
|
|
|
|
hmacSecret: env.CASAN_CP_JWT_HS256_SECRET,
|
|
|
|
|
clockSkewSeconds: Number.isFinite(skew) ? skew : -1,
|
|
|
|
|
roleClaim: env.CASAN_CP_JWT_ROLE_CLAIM || 'groups',
|
|
|
|
|
tenantClaim: env.CASAN_CP_JWT_TENANT_CLAIM || 'casan_tenant',
|
|
|
|
|
projectClaim: env.CASAN_CP_JWT_PROJECT_CLAIM || 'casan_project',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateAuthConfig(config: ControlPlaneAuthConfig): string[] {
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
const production = config.profile === 'prod' || config.profile === 'production' || config.profile === 'strict';
|
|
|
|
|
const nonLoopback = !['127.0.0.1', 'localhost', '::1'].includes(config.bind);
|
|
|
|
|
if (!['local', 'jwt'].includes(config.mode)) errors.push('auth_mode_invalid');
|
|
|
|
|
if ((production || nonLoopback) && config.mode !== 'jwt') errors.push('verified_identity_required');
|
|
|
|
|
if (config.clockSkewSeconds < 0 || config.clockSkewSeconds > 300) errors.push('auth_clock_skew_invalid');
|
|
|
|
|
if (config.mode === 'jwt') {
|
|
|
|
|
if (!config.issuer) errors.push('auth_issuer_required');
|
|
|
|
|
if (!config.audience) errors.push('auth_audience_required');
|
|
|
|
|
if (!config.algorithm) errors.push('auth_verification_key_required');
|
|
|
|
|
if (production && config.algorithm !== 'RS256') errors.push('auth_asymmetric_key_required_in_production');
|
|
|
|
|
if (config.algorithm === 'RS256') {
|
|
|
|
|
try {
|
|
|
|
|
if (!config.publicKey || createPublicKey(config.publicKey).asymmetricKeyType !== 'rsa') {
|
|
|
|
|
errors.push('auth_rsa_public_key_invalid');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
errors.push('auth_rsa_public_key_invalid');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (config.algorithm === 'HS256' && (!config.hmacSecret || Buffer.byteLength(config.hmacSecret) < 32)) {
|
|
|
|
|
errors.push('auth_hmac_secret_too_short');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return errors;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function auditAuthentication(decision: AuthenticationDecision, request: Request): void {
|
|
|
|
|
const stateRoot = process.env.CASAN_STATE_ROOT || join(APP_ROOT, '.specify');
|
|
|
|
|
const path = join(stateRoot, 'logs', 'auth', 'decisions.jsonl');
|
|
|
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
|
|
|
const record = {
|
|
|
|
|
schema_version: '1.0.0', category: 'runtime_control', policy_id: 'casan.control-plane.authentication',
|
|
|
|
|
timestamp: new Date().toISOString(), decision: decision.allowed ? 'allow' : 'deny', reason_code: decision.reasonCode,
|
|
|
|
|
actor: decision.claims?.subject, issuer: decision.claims?.issuer, tenant: decision.claims?.tenant,
|
|
|
|
|
project: decision.claims?.project, method: decision.claims?.authenticationMethod,
|
|
|
|
|
request: { method: request.method, path: request.path },
|
|
|
|
|
};
|
|
|
|
|
const fd = openSync(path, 'a', 0o600);
|
|
|
|
|
try {
|
|
|
|
|
writeSync(fd, `${JSON.stringify(record)}\n`);
|
|
|
|
|
fsyncSync(fd);
|
|
|
|
|
} finally {
|
|
|
|
|
closeSync(fd);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function overwriteVerifiedHeaders(request: Request, claims: VerifiedClaims): void {
|
|
|
|
|
for (const header of IDENTITY_HEADERS) delete request.headers[header];
|
|
|
|
|
const input: VerifiedClaimInput = {
|
|
|
|
|
subject: claims.subject,
|
|
|
|
|
issuer: claims.issuer,
|
|
|
|
|
roles: claims.roles,
|
|
|
|
|
tenant: claims.tenant,
|
|
|
|
|
project: claims.project,
|
|
|
|
|
};
|
|
|
|
|
const actor = actorFromVerifiedClaims(input);
|
|
|
|
|
request.headers['x-casan-actor'] = actor.actor;
|
|
|
|
|
request.headers['x-casan-role'] = actor.role;
|
|
|
|
|
request.headers['x-casan-project'] = actor.project;
|
|
|
|
|
request.headers['x-casan-tenant'] = actor.tenant;
|
|
|
|
|
request.headers['x-casan-identity-verified'] = '1';
|
|
|
|
|
request.headers['x-casan-identity-issuer'] = claims.issuer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createAuthMiddleware(config: ControlPlaneAuthConfig) {
|
|
|
|
|
const provider = config.mode === 'jwt' ? new JwtAuthProvider(config) : undefined;
|
|
|
|
|
return (request: Request, response: Response, next: NextFunction): void => {
|
|
|
|
|
if (!provider) {
|
|
|
|
|
request.headers['x-casan-identity-verified'] = 'local-development-only';
|
|
|
|
|
next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const headers = request.headers as Record<string, string | string[] | undefined>;
|
|
|
|
|
const decision = provider.authenticate(headers);
|
|
|
|
|
auditAuthentication(decision, request);
|
|
|
|
|
if (!decision.allowed || !decision.claims) {
|
|
|
|
|
response.status(401).json({ success: false, error: { code: decision.reasonCode, message: 'Authentication failed' } });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
overwriteVerifiedHeaders(request, decision.claims);
|
|
|
|
|
next();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function tokenFingerprint(token: string): string {
|
|
|
|
|
return createHash('sha256').update(token).digest('hex').slice(0, 12);
|
|
|
|
|
}
|