111 lines
4.8 KiB
TypeScript
111 lines
4.8 KiB
TypeScript
import { createHmac, createSign, generateKeyPairSync } from 'node:crypto';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
JwtAuthProvider,
|
|
validateAuthConfig,
|
|
type ControlPlaneAuthConfig,
|
|
} from '../src/common/auth-provider.js';
|
|
|
|
const secret = 'test-only-secret-with-sufficient-length';
|
|
const baseConfig: ControlPlaneAuthConfig = {
|
|
mode: 'jwt',
|
|
profile: 'test',
|
|
bind: '0.0.0.0',
|
|
issuer: 'https://issuer.test',
|
|
audience: 'casan-control-plane',
|
|
algorithm: 'HS256',
|
|
hmacSecret: secret,
|
|
clockSkewSeconds: 30,
|
|
roleClaim: 'groups',
|
|
tenantClaim: 'casan_tenant',
|
|
projectClaim: 'casan_project',
|
|
};
|
|
|
|
function token(payload: Record<string, unknown>, signingSecret = secret): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const signature = createHmac('sha256', signingSecret).update(`${header}.${body}`).digest('base64url');
|
|
return `${header}.${body}.${signature}`;
|
|
}
|
|
|
|
function rsToken(payload: Record<string, unknown>, privateKey: string): string {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const signer = createSign('RSA-SHA256');
|
|
signer.update(`${header}.${body}`);
|
|
signer.end();
|
|
return `${header}.${body}.${signer.sign(privateKey).toString('base64url')}`;
|
|
}
|
|
|
|
function claims(now: number, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
iss: baseConfig.issuer,
|
|
aud: baseConfig.audience,
|
|
sub: 'verified-user',
|
|
exp: now + 300,
|
|
iat: now,
|
|
groups: ['project-admin'],
|
|
casan_tenant: 'tenant-a',
|
|
casan_project: 'project-a',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('JWT provider verifies claims and ignores spoofed forwarded identity headers', () => {
|
|
const now = 1_800_000_000;
|
|
const provider = new JwtAuthProvider(baseConfig);
|
|
const decision = provider.authenticate({
|
|
authorization: `Bearer ${token(claims(now))}`,
|
|
'x-forwarded-user': 'attacker',
|
|
'x-casan-role': 'org-admin',
|
|
'x-casan-tenant': 'victim',
|
|
}, now);
|
|
assert.equal(decision.allowed, true);
|
|
assert.equal(decision.claims?.subject, 'verified-user');
|
|
assert.equal(decision.claims?.tenant, 'tenant-a');
|
|
assert.deepEqual(decision.claims?.roles, ['project-admin']);
|
|
});
|
|
|
|
test('JWT provider rejects missing, invalid, expired, wrong-audience and wrong-issuer tokens', () => {
|
|
const now = 1_800_000_000;
|
|
const provider = new JwtAuthProvider(baseConfig);
|
|
assert.equal(provider.authenticate({}, now).reasonCode, 'auth_bearer_token_required');
|
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now), 'wrong-secret')}` }, now).reasonCode, 'auth_signature_invalid');
|
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { exp: now - 31 }))}` }, now).reasonCode, 'auth_token_expired');
|
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { aud: 'wrong' }))}` }, now).reasonCode, 'auth_audience_invalid');
|
|
assert.equal(provider.authenticate({ authorization: `Bearer ${token(claims(now, { iss: 'https://wrong.test' }))}` }, now).reasonCode, 'auth_issuer_invalid');
|
|
});
|
|
|
|
test('production-compatible RS256 verification accepts a valid asymmetric token', () => {
|
|
const now = 1_800_000_000;
|
|
const keys = generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
});
|
|
const provider = new JwtAuthProvider({
|
|
...baseConfig,
|
|
profile: 'production',
|
|
algorithm: 'RS256',
|
|
publicKey: keys.publicKey,
|
|
hmacSecret: undefined,
|
|
});
|
|
const decision = provider.authenticate({ authorization: `Bearer ${rsToken(claims(now), keys.privateKey)}` }, now);
|
|
assert.equal(decision.allowed, true);
|
|
assert.equal(decision.claims?.subject, 'verified-user');
|
|
});
|
|
|
|
test('production and non-loopback startup refuse local or symmetric identity modes', () => {
|
|
assert.deepEqual(
|
|
validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'production' }),
|
|
['verified_identity_required'],
|
|
);
|
|
assert.ok(validateAuthConfig({ ...baseConfig, profile: 'production' }).includes('auth_asymmetric_key_required_in_production'));
|
|
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'development' }).includes('verified_identity_required'));
|
|
assert.ok(validateAuthConfig({ ...baseConfig, mode: 'local', profile: 'strict', bind: '127.0.0.1' }).includes('verified_identity_required'));
|
|
assert.ok(validateAuthConfig({
|
|
...baseConfig, profile: 'production', algorithm: 'RS256', publicKey: 'not-a-public-key', hmacSecret: undefined,
|
|
}).includes('auth_rsa_public_key_invalid'));
|
|
});
|