Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
4.3 KiB
TypeScript
125 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)
|
|
.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',
|
|
});
|
|
});
|
|
});
|