Some checks are pending
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Lint bridge (Biome) (push) Waiting to run
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
289 lines
9.5 KiB
TypeScript
289 lines
9.5 KiB
TypeScript
/**
|
|
* Tests unitaires DocmostJwtVerifier (R2.3b).
|
|
*
|
|
* Strategie : pas de mock — on signe a la volee avec `jose.SignJWT` (le meme
|
|
* code que celui qu'utilise NestJS `JwtService` cote Docmost). Le verifier est
|
|
* le code sous test, pas son entourage.
|
|
*/
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { logger } from '../../src/lib/logger.js';
|
|
import {
|
|
DocmostJwtVerifier,
|
|
decodeJwtAlg,
|
|
extractDocmostPermissions,
|
|
} from '../../src/middleware/docmost-jwt-verifier.js';
|
|
|
|
// Secret 64 chars — au-dessus du minimum 32.
|
|
const DEFAULT_SECRET = randomBytes(48).toString('hex');
|
|
const DEFAULT_ISSUER = 'Docmost';
|
|
|
|
interface SignOpts {
|
|
secret?: string;
|
|
issuer?: string;
|
|
audience?: string;
|
|
alg?: 'HS256' | 'HS384' | 'HS512';
|
|
expiresIn?: string;
|
|
claims?: Record<string, unknown>;
|
|
}
|
|
|
|
async function signHsJwt(opts: SignOpts = {}): Promise<string> {
|
|
const secret = opts.secret ?? DEFAULT_SECRET;
|
|
const claims = opts.claims ?? {
|
|
sub: 'user-uuid-1',
|
|
workspaceId: 'ws-uuid-1',
|
|
type: 'access',
|
|
};
|
|
const builder = new SignJWT(claims)
|
|
.setProtectedHeader({ alg: opts.alg ?? 'HS256' })
|
|
.setIssuedAt()
|
|
.setIssuer(opts.issuer ?? DEFAULT_ISSUER)
|
|
.setExpirationTime(opts.expiresIn ?? '5m');
|
|
if (opts.audience) builder.setAudience(opts.audience);
|
|
return builder.sign(Buffer.from(secret, 'utf-8'));
|
|
}
|
|
|
|
function makeVerifier(overrides?: { secret?: string; issuer?: string; audience?: string }) {
|
|
return new DocmostJwtVerifier({
|
|
secret: overrides?.secret ?? DEFAULT_SECRET,
|
|
issuer: overrides?.issuer ?? DEFAULT_ISSUER,
|
|
audience: overrides?.audience,
|
|
logger,
|
|
});
|
|
}
|
|
|
|
describe('DocmostJwtVerifier — constructor', () => {
|
|
it('throw si secret < 32 bytes', () => {
|
|
expect(
|
|
() => new DocmostJwtVerifier({ secret: 'x'.repeat(31), issuer: 'Docmost', logger }),
|
|
).toThrow(/secret too short/);
|
|
});
|
|
|
|
it('throw si issuer vide', () => {
|
|
expect(() => new DocmostJwtVerifier({ secret: DEFAULT_SECRET, issuer: '', logger })).toThrow(
|
|
/issuer required/,
|
|
);
|
|
});
|
|
|
|
it('construit ok avec secret 32 bytes minimum', () => {
|
|
expect(
|
|
() => new DocmostJwtVerifier({ secret: 'a'.repeat(32), issuer: 'Docmost', logger }),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('DocmostJwtVerifier — verify happy paths', () => {
|
|
it('JWT HS256 valide + issuer match -> ok + payload', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({
|
|
claims: {
|
|
sub: 'jane-uuid',
|
|
email: 'jane@acadenice.fr',
|
|
workspaceId: 'ws-1',
|
|
type: 'access',
|
|
sessionId: 'sess-1',
|
|
},
|
|
});
|
|
const { payload } = await v.verify(token);
|
|
expect(payload.sub).toBe('jane-uuid');
|
|
expect(payload.email).toBe('jane@acadenice.fr');
|
|
expect(payload.workspaceId).toBe('ws-1');
|
|
expect(payload.type).toBe('access');
|
|
expect(payload.sessionId).toBe('sess-1');
|
|
});
|
|
|
|
it('JWT HS384 valide -> ok (algo accepte)', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({ alg: 'HS384' });
|
|
const { payload } = await v.verify(token);
|
|
expect(payload.sub).toBe('user-uuid-1');
|
|
});
|
|
|
|
it('JWT HS512 valide -> ok', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({ alg: 'HS512' });
|
|
const { payload } = await v.verify(token);
|
|
expect(payload.sub).toBe('user-uuid-1');
|
|
});
|
|
|
|
it('JWT sans claim acadenice_permissions -> ok, scopes vides cote caller', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt();
|
|
const { payload } = await v.verify(token);
|
|
expect(extractDocmostPermissions(payload)).toEqual([]);
|
|
});
|
|
|
|
it('JWT avec claim acadenice_permissions[] -> scopes alimentes', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({
|
|
claims: {
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
acadenice_permissions: ['read:tables', 'write:tables'],
|
|
},
|
|
});
|
|
const { payload } = await v.verify(token);
|
|
expect(extractDocmostPermissions(payload)).toEqual(['read:tables', 'write:tables']);
|
|
});
|
|
|
|
it('JWT avec audience matching quand verifier configure -> ok', async () => {
|
|
const v = makeVerifier({ audience: 'formation-hub-bridge' });
|
|
const token = await signHsJwt({ audience: 'formation-hub-bridge' });
|
|
const { payload } = await v.verify(token);
|
|
expect(payload.sub).toBe('user-uuid-1');
|
|
});
|
|
|
|
it('JWT sans audience claim quand verifier ne configure pas d audience -> ok', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt();
|
|
const { payload } = await v.verify(token);
|
|
expect(payload).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('DocmostJwtVerifier — verify rejections', () => {
|
|
it('JWT HS256 expired -> throws AUTH_INVALID', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({ expiresIn: '-1s' });
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID', status: 401 });
|
|
});
|
|
|
|
it('JWT HS256 wrong issuer -> throws', async () => {
|
|
const v = makeVerifier({ issuer: 'Docmost' });
|
|
const token = await signHsJwt({ issuer: 'EvilCorp' });
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT HS256 wrong signature (different secret) -> throws', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({ secret: randomBytes(48).toString('hex') });
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT RS256 (mauvais algo) -> throws (algorithm mismatch)', async () => {
|
|
const v = makeVerifier();
|
|
const { privateKey } = await generateKeyPair('RS256');
|
|
const rsaToken = await new SignJWT({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
})
|
|
.setProtectedHeader({ alg: 'RS256' })
|
|
.setIssuedAt()
|
|
.setIssuer(DEFAULT_ISSUER)
|
|
.setExpirationTime('5m')
|
|
.sign(privateKey);
|
|
await expect(v.verify(rsaToken)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT audience mismatch quand verifier exige audience -> throws', async () => {
|
|
const v = makeVerifier({ audience: 'formation-hub-bridge' });
|
|
const token = await signHsJwt({ audience: 'other-app' });
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT sans audience quand verifier exige audience -> throws', async () => {
|
|
const v = makeVerifier({ audience: 'formation-hub-bridge' });
|
|
const token = await signHsJwt();
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT sans claim sub -> throws (claim requis)', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({
|
|
claims: { workspaceId: 'w', type: 'access' },
|
|
});
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT sans claim workspaceId -> throws', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({
|
|
claims: { sub: 'u', type: 'access' },
|
|
});
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT sans claim type -> throws', async () => {
|
|
const v = makeVerifier();
|
|
const token = await signHsJwt({
|
|
claims: { sub: 'u', workspaceId: 'w' },
|
|
});
|
|
await expect(v.verify(token)).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
|
|
it('JWT malforme -> throws', async () => {
|
|
const v = makeVerifier();
|
|
await expect(v.verify('not.a.jwt')).rejects.toMatchObject({ code: 'AUTH_INVALID' });
|
|
});
|
|
});
|
|
|
|
describe('decodeJwtAlg', () => {
|
|
it('retourne alg pour un JWT HS256', async () => {
|
|
const token = await signHsJwt();
|
|
expect(decodeJwtAlg(token)).toBe('HS256');
|
|
});
|
|
|
|
it('retourne alg pour un JWT HS384', async () => {
|
|
const token = await signHsJwt({ alg: 'HS384' });
|
|
expect(decodeJwtAlg(token)).toBe('HS384');
|
|
});
|
|
|
|
it('retourne alg pour un JWT RS256', async () => {
|
|
const { privateKey } = await generateKeyPair('RS256');
|
|
const token = await new SignJWT({})
|
|
.setProtectedHeader({ alg: 'RS256' })
|
|
.setIssuedAt()
|
|
.sign(privateKey);
|
|
expect(decodeJwtAlg(token)).toBe('RS256');
|
|
});
|
|
|
|
it('retourne null pour une string sans dot', () => {
|
|
expect(decodeJwtAlg('garbage')).toBeNull();
|
|
});
|
|
|
|
it('retourne null pour un header non-base64', () => {
|
|
expect(decodeJwtAlg('!!!!.payload.sig')).toBeNull();
|
|
});
|
|
|
|
it('retourne null si JSON sans alg', () => {
|
|
const headerB64 = Buffer.from(JSON.stringify({ typ: 'JWT' }), 'utf-8').toString('base64url');
|
|
expect(decodeJwtAlg(`${headerB64}.x.y`)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractDocmostPermissions', () => {
|
|
it('retourne [] si claim absent', () => {
|
|
expect(extractDocmostPermissions({ sub: 'u', workspaceId: 'w', type: 'access' })).toEqual([]);
|
|
});
|
|
|
|
it('retourne [] si claim pas un array', () => {
|
|
expect(
|
|
extractDocmostPermissions({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
// biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance
|
|
acadenice_permissions: 'foo' as any,
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('filtre les non-strings et vides', () => {
|
|
expect(
|
|
extractDocmostPermissions({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
// biome-ignore lint/suspicious/noExplicitAny: test du runtime tolerance
|
|
acadenice_permissions: ['ok', 1, '', null, 'good'] as any,
|
|
}),
|
|
).toEqual(['ok', 'good']);
|
|
});
|
|
});
|
|
|
|
// Petite verif que `exportJWK` est utilise nulle part (pas de warning unused).
|
|
void exportJWK;
|