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
726 lines
25 KiB
TypeScript
726 lines
25 KiB
TypeScript
/**
|
|
* Tests integration auth middleware (R1 generique) — dual mode service-token + OIDC.
|
|
*
|
|
* R1 — Plus de lookup PersonneRepo, plus de roles formation-hub. Le claim
|
|
* `acadenice_permissions[]` du JWT alimente directement les scopes.
|
|
*
|
|
* Strategie JWKS : mini serveur HTTP local qui expose un /.well-known/jwks.json
|
|
* avec une cle RSA generee a la volee via `jose.generateKeyPair`. Plus realiste
|
|
* que mocker `createRemoteJWKSet` car teste fetch reel + parsing reel.
|
|
*/
|
|
|
|
import { randomBytes } from 'node:crypto';
|
|
import { type Server, createServer } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { Hono } from 'hono';
|
|
import { type CryptoKey, type JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { logger } from '../../src/lib/logger.js';
|
|
import {
|
|
type ApiTokenRecord,
|
|
type AuthVariables,
|
|
authMiddleware,
|
|
extractPermissions,
|
|
hasScope,
|
|
parseTokens,
|
|
requireScope,
|
|
} from '../../src/middleware/auth.js';
|
|
import { DocmostJwtVerifier } from '../../src/middleware/docmost-jwt-verifier.js';
|
|
import { errorHandler } from '../../src/middleware/error-handler.js';
|
|
import { OidcVerifier } from '../../src/middleware/oidc-verifier.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JWKS mini server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface JwksFixture {
|
|
server: Server;
|
|
url: string;
|
|
privateKey: CryptoKey;
|
|
publicJwk: JWK;
|
|
kid: string;
|
|
hits: number;
|
|
}
|
|
|
|
async function startJwksServer(): Promise<JwksFixture> {
|
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
|
const publicJwk = await exportJWK(publicKey);
|
|
const kid = 'test-key-1';
|
|
publicJwk.kid = kid;
|
|
publicJwk.alg = 'RS256';
|
|
publicJwk.use = 'sig';
|
|
|
|
const fixture: JwksFixture = {
|
|
server: createServer(),
|
|
url: '',
|
|
privateKey,
|
|
publicJwk,
|
|
kid,
|
|
hits: 0,
|
|
};
|
|
|
|
fixture.server.on('request', (req, res) => {
|
|
if (req.url === '/jwks.json') {
|
|
fixture.hits += 1;
|
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify({ keys: [publicJwk] }));
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end();
|
|
});
|
|
|
|
await new Promise<void>((resolve) => fixture.server.listen(0, '127.0.0.1', resolve));
|
|
const addr = fixture.server.address() as AddressInfo;
|
|
fixture.url = `http://127.0.0.1:${addr.port}/jwks.json`;
|
|
return fixture;
|
|
}
|
|
|
|
async function signJwt(
|
|
fx: JwksFixture,
|
|
claims: Record<string, unknown>,
|
|
overrides?: { issuer?: string; audience?: string; expiresIn?: string; alg?: string },
|
|
): Promise<string> {
|
|
const builder = new SignJWT(claims)
|
|
.setProtectedHeader({ alg: overrides?.alg ?? 'RS256', kid: fx.kid })
|
|
.setIssuedAt()
|
|
.setIssuer(overrides?.issuer ?? 'https://auth.test/issuer')
|
|
.setAudience(overrides?.audience ?? 'formation-hub-bridge')
|
|
.setExpirationTime(overrides?.expiresIn ?? '5m');
|
|
return builder.sign(fx.privateKey);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App builder
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BuildAppOpts {
|
|
tokens?: ApiTokenRecord[];
|
|
oidcEnabled: boolean;
|
|
jwks?: JwksFixture;
|
|
groupsScopesMap?: Record<string, string[]>;
|
|
docmostSecret?: string;
|
|
docmostIssuer?: string;
|
|
docmostAudience?: string;
|
|
}
|
|
|
|
function buildApp(opts: BuildAppOpts) {
|
|
const tokens = opts.tokens ?? [];
|
|
const map = new Map<string, ApiTokenRecord>();
|
|
for (const t of tokens) map.set(t.token, t);
|
|
|
|
let verifier: OidcVerifier | null = null;
|
|
if (opts.oidcEnabled) {
|
|
if (!opts.jwks) throw new Error('jwks fixture required when oidcEnabled');
|
|
verifier = new OidcVerifier({
|
|
issuer: 'https://auth.test/issuer',
|
|
jwksUri: opts.jwks.url,
|
|
audience: 'formation-hub-bridge',
|
|
logger,
|
|
jwksCacheMaxAgeMs: 1000,
|
|
});
|
|
}
|
|
|
|
let docmostJwt: DocmostJwtVerifier | null = null;
|
|
if (opts.docmostSecret) {
|
|
docmostJwt = new DocmostJwtVerifier({
|
|
secret: opts.docmostSecret,
|
|
issuer: opts.docmostIssuer ?? 'Docmost',
|
|
audience: opts.docmostAudience,
|
|
logger,
|
|
});
|
|
}
|
|
|
|
const app = new Hono<{ Variables: AuthVariables }>();
|
|
app.onError(errorHandler);
|
|
app.use(
|
|
'/protected/*',
|
|
authMiddleware({
|
|
tokens: map,
|
|
oidc: verifier,
|
|
docmostJwt,
|
|
groupsScopesMap: opts.groupsScopesMap ?? {},
|
|
logger,
|
|
}),
|
|
);
|
|
|
|
app.get('/protected/me', (c) => {
|
|
const user = c.get('user');
|
|
return c.json({ user });
|
|
});
|
|
app.get('/protected/needs-read-tables', requireScope('read:tables'), (c) =>
|
|
c.json({ ok: true, scopes: c.get('user').scopes }),
|
|
);
|
|
app.get('/protected/needs-admin', requireScope('admin:write'), (c) => c.json({ ok: true }));
|
|
|
|
return { app };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseTokens', () => {
|
|
it('parse JSON valide', () => {
|
|
const map = parseTokens(
|
|
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:tables'] }]),
|
|
);
|
|
expect(map.get('brg_x')?.name).toBe('a');
|
|
});
|
|
|
|
it('retourne map vide si raw vide', () => {
|
|
expect(parseTokens(undefined).size).toBe(0);
|
|
expect(parseTokens('').size).toBe(0);
|
|
});
|
|
|
|
it('throw si JSON invalide', () => {
|
|
expect(() => parseTokens('{nope')).toThrow(/JSON/);
|
|
});
|
|
|
|
it('throw si pas un array', () => {
|
|
expect(() => parseTokens('{"foo": 1}')).toThrow(/tableau/);
|
|
});
|
|
|
|
it('throw si entree manque token/name/scopes', () => {
|
|
expect(() => parseTokens('[{"token":"x"}]')).toThrow();
|
|
expect(() => parseTokens('[{"token":"x","name":"y"}]')).toThrow();
|
|
expect(() => parseTokens('[{"token":"x","name":"y","scopes":[1]}]')).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('hasScope', () => {
|
|
it('match exact', () => {
|
|
expect(hasScope(new Set(['read:tables']), 'read:tables')).toBe(true);
|
|
expect(hasScope(new Set(['read:tables']), 'write:tables')).toBe(false);
|
|
});
|
|
it('admin:* couvre tout', () => {
|
|
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
|
|
});
|
|
it('prefix wildcard couvre meme prefix', () => {
|
|
expect(hasScope(new Set(['read:*']), 'read:tables')).toBe(true);
|
|
expect(hasScope(new Set(['read:*']), 'write:tables')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('extractPermissions', () => {
|
|
it('retourne le claim acadenice_permissions[] si present', () => {
|
|
expect(extractPermissions({ acadenice_permissions: ['a', 'b'] })).toEqual(['a', 'b']);
|
|
});
|
|
it('retourne [] si claim absent', () => {
|
|
expect(extractPermissions({})).toEqual([]);
|
|
});
|
|
it('retourne [] si claim pas un array', () => {
|
|
expect(extractPermissions({ acadenice_permissions: 'foo' })).toEqual([]);
|
|
});
|
|
it('filtre les valeurs non-strings et vides', () => {
|
|
expect(extractPermissions({ acadenice_permissions: ['ok', 1, '', null, 'good'] })).toEqual([
|
|
'ok',
|
|
'good',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('auth middleware — service tokens', () => {
|
|
const tokens: ApiTokenRecord[] = [
|
|
{ token: 'brg_valid', name: 'demo', scopes: ['read:tables', 'admin:write'] },
|
|
];
|
|
|
|
it('cas 1 — service token valid via Bearer -> 200 + source=service-token', async () => {
|
|
const { app } = buildApp({ tokens, oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'Bearer brg_valid' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
user: { source: string; scopes: string[]; tokenId: string };
|
|
};
|
|
expect(body.user.source).toBe('service-token');
|
|
expect(body.user.tokenId).toBe('demo');
|
|
expect(body.user.scopes).toContain('read:tables');
|
|
});
|
|
|
|
it('service token valid via ApiKey scheme -> 200', async () => {
|
|
const { app } = buildApp({ tokens, oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'ApiKey brg_valid' },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it('cas 2 — service token invalid -> 401 AUTH_INVALID', async () => {
|
|
const { app } = buildApp({ tokens, oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'Bearer brg_unknown' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('header sans scheme reconnu -> 401 AUTH_INVALID', async () => {
|
|
const { app } = buildApp({ tokens, oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'Token brg_valid' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('aucun header -> 401 AUTH_REQUIRED', async () => {
|
|
const { app } = buildApp({ tokens, oidcEnabled: false });
|
|
const res = await app.request('/protected/me');
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_REQUIRED');
|
|
});
|
|
});
|
|
|
|
describe('auth middleware — OIDC desactive + JWT envoye', () => {
|
|
it('cas 3 — OIDC off + Bearer non-brg -> 401 (pas de fallback silencieux)', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'Bearer eyJhbGciOiJSUzI1NiJ9.fake.fake' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('OIDC off + cookie authToken -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Cookie: 'authToken=eyJhbGciOiJSUzI1NiJ9.fake.fake' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('auth middleware — OIDC actif (R1 generique)', () => {
|
|
let jwks: JwksFixture;
|
|
|
|
beforeAll(async () => {
|
|
jwks = await startJwksServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => jwks.server.close(() => resolve()));
|
|
});
|
|
|
|
it('cas 4 — JWT valid + claim acadenice_permissions[] -> 200, scopes alimentes directement', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, {
|
|
email: 'jane@acadenice.fr',
|
|
sub: 'authentik-jane-uuid',
|
|
groups: [],
|
|
acadenice_permissions: ['read:tables', 'write:tables'],
|
|
});
|
|
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
user: {
|
|
source: string;
|
|
scopes: string[];
|
|
permissions: string[];
|
|
sub: string;
|
|
email: string;
|
|
};
|
|
};
|
|
expect(body.user.source).toBe('oidc-jwt');
|
|
expect(body.user.email).toBe('jane@acadenice.fr');
|
|
expect(body.user.sub).toBe('authentik-jane-uuid');
|
|
expect(body.user.scopes).toContain('read:tables');
|
|
expect(body.user.scopes).toContain('write:tables');
|
|
expect(body.user.permissions).toEqual(['read:tables', 'write:tables']);
|
|
});
|
|
|
|
it('cas 4b — JWT sans claim acadenice_permissions[] : auth OK mais scopes vides', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, {
|
|
email: 'jane@acadenice.fr',
|
|
sub: 'sub-x',
|
|
groups: [],
|
|
});
|
|
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { permissions: string[]; scopes: string[] } };
|
|
expect(body.user.permissions).toEqual([]);
|
|
expect(body.user.scopes).toEqual([]);
|
|
});
|
|
|
|
it('cas 4c — JWT avec groups Authentik mappes -> scopes via groupsScopesMap', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
groupsScopesMap: { 'role-formateur': ['read:tables'] },
|
|
});
|
|
const token = await signJwt(jwks, {
|
|
email: 'fmt@acadenice.fr',
|
|
groups: ['role-formateur'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { scopes: string[]; groups: string[] } };
|
|
expect(body.user.scopes).toContain('read:tables');
|
|
expect(body.user.groups).toContain('role-formateur');
|
|
});
|
|
|
|
it('cas 4d — union groups + permissions explicites', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
groupsScopesMap: { 'role-x': ['read:tables'] },
|
|
});
|
|
const token = await signJwt(jwks, {
|
|
email: 'x@acadenice.fr',
|
|
groups: ['role-x'],
|
|
acadenice_permissions: ['write:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
const body = (await res.json()) as { user: { scopes: string[] } };
|
|
expect(body.user.scopes).toContain('read:tables');
|
|
expect(body.user.scopes).toContain('write:tables');
|
|
});
|
|
|
|
it('cas 5 — JWT signature invalide -> 401 AUTH_INVALID', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, { email: 'x@y.z' });
|
|
const tampered = `${token.slice(0, -16)}AAAAAAAAAAAAAAAA`;
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${tampered}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('cas 6 — JWT expired -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, { email: 'x@y.z' }, { expiresIn: '-1s' });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('cas 7 — JWT issuer different -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, { email: 'x@y.z' }, { issuer: 'https://evil.example/' });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('cas 8 — JWT audience different -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, { email: 'x@y.z' }, { audience: 'other-app' });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('cas 9 — Cookie authToken valid -> 200, source=oidc-cookie', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, {
|
|
email: 'cookie@acadenice.fr',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Cookie: `authToken=${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { source: string; email: string } };
|
|
expect(body.user.source).toBe('oidc-cookie');
|
|
expect(body.user.email).toBe('cookie@acadenice.fr');
|
|
});
|
|
|
|
it('cas 10 — requireScope match via permissions claim -> 200', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, {
|
|
email: 'a@b.c',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/needs-read-tables', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { ok: boolean; scopes: string[] };
|
|
expect(body.ok).toBe(true);
|
|
expect(body.scopes).toContain('read:tables');
|
|
});
|
|
|
|
it('cas 11 — requireScope deny -> 403 FORBIDDEN_SCOPE', async () => {
|
|
const { app } = buildApp({ oidcEnabled: true, jwks });
|
|
const token = await signJwt(jwks, {
|
|
email: 'a@b.c',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/needs-admin', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// R2.3b — Mode JWT HMAC DocAdenice
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DOCMOST_SECRET = randomBytes(48).toString('hex');
|
|
|
|
async function signDocmostJwt(
|
|
claims: Record<string, unknown>,
|
|
overrides?: { issuer?: string; audience?: string; expiresIn?: string; secret?: string },
|
|
): Promise<string> {
|
|
const builder = new SignJWT(claims)
|
|
.setProtectedHeader({ alg: 'HS256' })
|
|
.setIssuedAt()
|
|
.setIssuer(overrides?.issuer ?? 'Docmost')
|
|
.setExpirationTime(overrides?.expiresIn ?? '5m');
|
|
if (overrides?.audience) builder.setAudience(overrides.audience);
|
|
return builder.sign(Buffer.from(overrides?.secret ?? DOCMOST_SECRET, 'utf-8'));
|
|
}
|
|
|
|
describe('auth middleware — JWT HMAC DocAdenice (R2.3b)', () => {
|
|
it('Bearer JWT HS256 DocAdenice valide -> 200 + source=docmost-jwt', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt({
|
|
sub: 'docmost-user-uuid',
|
|
email: 'corentin@acadenice.fr',
|
|
workspaceId: 'ws-1',
|
|
type: 'access',
|
|
sessionId: 'sess-1',
|
|
acadenice_permissions: ['read:tables', 'write:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
user: {
|
|
source: string;
|
|
email: string;
|
|
sub: string;
|
|
scopes: string[];
|
|
permissions: string[];
|
|
groups: string[];
|
|
};
|
|
};
|
|
expect(body.user.source).toBe('docmost-jwt');
|
|
expect(body.user.email).toBe('corentin@acadenice.fr');
|
|
expect(body.user.sub).toBe('docmost-user-uuid');
|
|
expect(body.user.scopes).toEqual(['read:tables', 'write:tables']);
|
|
expect(body.user.permissions).toEqual(['read:tables', 'write:tables']);
|
|
expect(body.user.groups).toEqual([]);
|
|
});
|
|
|
|
it('Bearer JWT HS256 sans permissions claim -> auth OK + scopes vides', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { scopes: string[]; permissions: string[] } };
|
|
expect(body.user.scopes).toEqual([]);
|
|
expect(body.user.permissions).toEqual([]);
|
|
});
|
|
|
|
it('Bearer JWT HS256 mais DocAdenice mode desactive -> 401 AUTH_INVALID', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false });
|
|
const token = await signDocmostJwt({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('Cookie authToken HS256 DocAdenice -> 200 + source=docmost-cookie', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt({
|
|
sub: 'cookie-user',
|
|
email: 'cookie@acadenice.fr',
|
|
workspaceId: 'ws-1',
|
|
type: 'access',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Cookie: `authToken=${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { source: string; email: string } };
|
|
expect(body.user.source).toBe('docmost-cookie');
|
|
expect(body.user.email).toBe('cookie@acadenice.fr');
|
|
});
|
|
|
|
it('JWT HS256 expired -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt(
|
|
{ sub: 'u', workspaceId: 'w', type: 'access' },
|
|
{ expiresIn: '-1s' },
|
|
);
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('JWT HS256 wrong signature (mauvais secret) -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt(
|
|
{ sub: 'u', workspaceId: 'w', type: 'access' },
|
|
{ secret: randomBytes(48).toString('hex') },
|
|
);
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('JWT HS256 issuer mismatch -> 401', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt(
|
|
{ sub: 'u', workspaceId: 'w', type: 'access' },
|
|
{ issuer: 'Acme' },
|
|
);
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('JWT HS256 + DocAdenice permissions -> requireScope match -> 200', async () => {
|
|
const { app } = buildApp({ oidcEnabled: false, docmostSecret: DOCMOST_SECRET });
|
|
const token = await signDocmostJwt({
|
|
sub: 'u',
|
|
workspaceId: 'w',
|
|
type: 'access',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/needs-read-tables', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('auth middleware — coexistence Authentik + DocAdenice (R2.3b)', () => {
|
|
let jwks: JwksFixture;
|
|
|
|
beforeAll(async () => {
|
|
jwks = await startJwksServer();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => jwks.server.close(() => resolve()));
|
|
});
|
|
|
|
it('JWT RS256 Authentik valide -> route vers OIDC (mode 2)', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
docmostSecret: DOCMOST_SECRET,
|
|
});
|
|
const token = await signJwt(jwks, {
|
|
email: 'jane@acadenice.fr',
|
|
sub: 'authentik-jane',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { source: string } };
|
|
expect(body.user.source).toBe('oidc-jwt');
|
|
});
|
|
|
|
it('JWT HS256 DocAdenice valide -> route vers DocAdenice (mode 3)', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
docmostSecret: DOCMOST_SECRET,
|
|
});
|
|
const token = await signDocmostJwt({
|
|
sub: 'docmost-user',
|
|
workspaceId: 'ws-1',
|
|
type: 'access',
|
|
acadenice_permissions: ['read:tables'],
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { user: { source: string } };
|
|
expect(body.user.source).toBe('docmost-jwt');
|
|
});
|
|
|
|
it('JWT RS256 mais OIDC desactive (DocAdenice seul) -> 401 (algo mismatch)', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: false,
|
|
docmostSecret: DOCMOST_SECRET,
|
|
});
|
|
const token = await signJwt(jwks, { email: 'x@y.z' });
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('JWT avec algo none -> 401 (algo non supporte)', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
docmostSecret: DOCMOST_SECRET,
|
|
});
|
|
// header algo "none" — manuellement forge.
|
|
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }), 'utf-8').toString(
|
|
'base64url',
|
|
);
|
|
const payload = Buffer.from(JSON.stringify({ sub: 'u' }), 'utf-8').toString('base64url');
|
|
const noneJwt = `${header}.${payload}.`;
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: `Bearer ${noneJwt}` },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
const body = (await res.json()) as { error: { code: string } };
|
|
expect(body.error.code).toBe('AUTH_INVALID');
|
|
});
|
|
|
|
it('JWT garbage (header non decodable) -> 401', async () => {
|
|
const { app } = buildApp({
|
|
oidcEnabled: true,
|
|
jwks,
|
|
docmostSecret: DOCMOST_SECRET,
|
|
});
|
|
const res = await app.request('/protected/me', {
|
|
headers: { Authorization: 'Bearer notajwt.atall.bro' },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|