Files
0pt/tests/auth/zitadel.test.ts
admin 063206d8b0
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / sonar (pull_request) Skipped
CI (SIAX Cloud) / security (pull_request) Failing after 4s
Security Scan (SIAX Cloud) / security (pull_request) Failing after 11s
masterplan-lock / check (pull_request) Successful in 38s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m5s
CI (SIAX Cloud) / quality (pull_request) Successful in 9m58s
feat(auth): adopt @siax/auth-zitadel canonical claim normalizer
2026-11-04 02:23:21 +01:00

126 lines
4.3 KiB
TypeScript

/**
* Unit tests for src/auth/zitadel.ts `verifyBearer`, using a locally-generated
* RSA keypair + `createLocalJWKSet` so there is ZERO network access: we sign
* our own tokens and inject the matching JWKS resolver.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import {
SignJWT,
generateKeyPair,
exportJWK,
createLocalJWKSet,
type JWTVerifyGetKey,
type KeyLike,
} from 'jose';
import { verifyBearer, deriveTenantId, BearerVerificationError } from '@/src/auth/zitadel';
const ISSUER = 'https://test-issuer.siax.io';
const KID = 'test-key-1';
let privateKey: KeyLike;
let jwks: JWTVerifyGetKey;
beforeAll(async () => {
const { publicKey, privateKey: pk } = await generateKeyPair('RS256');
privateKey = pk;
const jwk = await exportJWK(publicKey);
jwk.kid = KID;
jwk.alg = 'RS256';
jwks = createLocalJWKSet({ keys: [jwk] });
});
interface SignOpts {
issuer?: string;
expirationTime?: string | number;
}
async function sign(claims: Record<string, unknown>, opts: SignOpts = {}): Promise<string> {
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setIssuedAt()
.setIssuer(opts.issuer ?? ISSUER)
.setAudience('siax-test')
.setExpirationTime(opts.expirationTime ?? '2h')
.sign(privateKey);
}
describe('deriveTenantId', () => {
it('prefers the Zitadel org id claim', () => {
expect(
deriveTenantId({ 'urn:zitadel:iam:org:id': 'org_1', tenant_id: 't_custom' }),
).toBe('org_1');
});
it('falls back to the resourceowner id, then tenant_id, then tenant', () => {
expect(deriveTenantId({ 'urn:zitadel:iam:user:resourceowner:id': 'org_ro' })).toBe('org_ro');
expect(deriveTenantId({ tenant_id: 't_x' })).toBe('t_x');
expect(deriveTenantId({ tenant: 't_y' })).toBe('t_y');
});
it('honors an explicit override claim key when present', () => {
expect(deriveTenantId({ custom_tid: 't_over', 'urn:zitadel:iam:org:id': 'org_1' }, 'custom_tid')).toBe(
't_over',
);
});
it('returns undefined when no tenant claim is present', () => {
expect(deriveTenantId({ sub: 'u1' })).toBeUndefined();
});
});
describe('verifyBearer', () => {
it('verifies a valid token and derives the principal (sub + tenant)', async () => {
const token = await sign({ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' });
const principal = await verifyBearer(token, { issuer: ISSUER, keyResolver: jwks });
expect(principal.sub).toBe('user_1');
expect(principal.tenantId).toBe('tenant_x');
expect(principal.claims.iss).toBe(ISSUER);
});
it('honors the custom tenant_id claim', async () => {
const token = await sign({ sub: 'user_2', tenant_id: 't_protosell' });
const principal = await verifyBearer(token, { issuer: ISSUER, keyResolver: jwks });
expect(principal.tenantId).toBe('t_protosell');
});
it('rejects a tampered token (bad signature)', async () => {
const token = await sign({ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' });
const firstDot = token.indexOf('.');
const secondDot = token.indexOf('.', firstDot + 1);
const mid = Math.floor((firstDot + secondDot) / 2);
const ch = token.charAt(mid);
const tampered = token.slice(0, mid) + (ch === 'a' ? 'b' : 'a') + token.slice(mid + 1);
await expect(verifyBearer(tampered, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects an expired token', async () => {
const token = await sign(
{ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' },
{ expirationTime: '-1h' },
);
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects a token from the wrong issuer', async () => {
const token = await sign(
{ sub: 'user_1', 'urn:zitadel:iam:org:id': 'tenant_x' },
{ issuer: 'https://evil-issuer.example' },
);
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toBeInstanceOf(
BearerVerificationError,
);
});
it('rejects a validly-signed token that carries no tenant claim', async () => {
const token = await sign({ sub: 'user_1' });
await expect(verifyBearer(token, { issuer: ISSUER, keyResolver: jwks })).rejects.toMatchObject({
reason: 'token_missing_tenant_claim',
});
});
});