fix(security): JWT-Audience gegen OIDC_CLIENT_ID prüfen

Bisher prüfte das Backend nur Issuer und Signatur. Ein Access-Token, das
derselbe Authentik-Provider für eine andere Anwendung ausgestellt hat,
wurde ebenfalls akzeptiert.

- JwtStrategy verlangt aud == OIDC_CLIENT_ID (jwt-optionen.ts, getestet
  mit echten RS256-Tokens). Ohne OIDC_CLIENT_ID bleibt die Prüfung aus,
  mit Warnung beim Start.
- docker-compose.yml übergibt OIDC_CLIENT_ID jetzt ans Backend, Standard:
  dieselbe Client-ID wie das Frontend (VITE_OIDC_CLIENT_ID).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bjoernpoettkerandClaude Opus 5.5 committed 2026-11-04 10:46:57 +01:00
1 parent b11294ec10
commit c28475f71f
4 files changed
+92 -5

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ services:
- OLLAMA_MODEL=${OLLAMA_MODEL:-llava} - OLLAMA_MODEL=${OLLAMA_MODEL:-llava}
- SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive} - SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive}
- OIDC_ISSUER=${OIDC_ISSUER:-} - OIDC_ISSUER=${OIDC_ISSUER:-}
# Audience-Prüfung der Tokens – muss dieselbe Client-ID wie im Frontend sein
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-${VITE_OIDC_CLIENT_ID:-ogOeIlrcsf2spjuwyOc7CuAPrRkRSt3RXfYguoLn}}
- SMTP_HOST=${SMTP_HOST:-} - SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
- SMTP_SECURE=${SMTP_SECURE:-false} - SMTP_SECURE=${SMTP_SECURE:-false}
@@ -0,0 +1,46 @@
import { generateKeyPairSync } from 'crypto';
import * as jwt from 'jsonwebtoken';
import { jwtPruefOptionen } from './jwt-optionen';
describe('jwtPruefOptionen', () => {
it('verlangt die eigene Client-ID als Audience, wenn sie gesetzt ist', () => {
expect(
jwtPruefOptionen('https://auth.example/application/o/pm/', 'client-pm'),
).toEqual({
issuer: 'https://auth.example/application/o/pm/',
audience: 'client-pm',
algorithms: ['RS256'],
jwksUri: 'https://auth.example/application/o/pm/jwks/',
});
});
it('lässt die Audience weg, wenn keine Client-ID konfiguriert ist', () => {
const o = jwtPruefOptionen('https://auth.example/application/o/pm', '');
expect(o.audience).toBeUndefined();
expect(o.jwksUri).toBe('https://auth.example/application/o/pm/jwks/');
});
// Prüft, dass die Optionen in jsonwebtoken (das passport-jwt intern nutzt)
// tatsächlich fremde Tokens desselben Issuers abweisen.
it('weist Tokens anderer Anwendungen desselben Authentik ab', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const o = jwtPruefOptionen('https://auth.example/', 'client-pm');
const token = (aud: string) =>
jwt.sign({ sub: 'u1' }, privateKey, {
algorithm: 'RS256',
issuer: o.issuer,
audience: aud,
});
const pruefe = (t: string) =>
jwt.verify(t, publicKey, {
issuer: o.issuer,
audience: o.audience,
algorithms: o.algorithms,
});
expect(() => pruefe(token('client-pm'))).not.toThrow();
expect(() => pruefe(token('client-andere-app'))).toThrow(/audience/);
});
});
@@ -0,0 +1,27 @@
import type { Algorithm } from 'jsonwebtoken';
export interface JwtPruefOptionen {
issuer: string;
/** Client-ID dieser Anwendung; ohne sie wird `aud` nicht geprüft. */
audience?: string;
algorithms: Algorithm[];
jwksUri: string;
}
/**
* Prüfoptionen für Access-Tokens von Authentik. Ohne Audience-Prüfung würde
* jedes Token desselben Issuers akzeptiert – also auch eines, das Authentik
* für eine andere Anwendung mit demselben Provider ausgestellt hat.
*/
export function jwtPruefOptionen(
issuer: string,
clientId: string | undefined,
): JwtPruefOptionen {
const basis = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
return {
issuer,
...(clientId ? { audience: clientId } : {}),
algorithms: ['RS256'],
jwksUri: `${basis}/jwks/`,
};
}
+17 -5
View File
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt'; import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { passportJwtSecret } from 'jwks-rsa'; import { passportJwtSecret } from 'jwks-rsa';
import { mapGroupsToPermissions } from './permissions.enum'; import { mapGroupsToPermissions } from './permissions.enum';
import { jwtPruefOptionen } from './jwt-optionen';
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service'; import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
@Injectable() @Injectable()
@@ -12,20 +13,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
configService: ConfigService, configService: ConfigService,
private readonly identitySync: UserIdentitySyncService, private readonly identitySync: UserIdentitySyncService,
) { ) {
const issuer = configService.get<string>('OIDC_ISSUER', ''); const optionen = jwtPruefOptionen(
configService.get<string>('OIDC_ISSUER', ''),
configService.get<string>('OIDC_CLIENT_ID', ''),
);
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
issuer, issuer: optionen.issuer,
algorithms: ['RS256'], audience: optionen.audience,
algorithms: optionen.algorithms,
secretOrKeyProvider: passportJwtSecret({ secretOrKeyProvider: passportJwtSecret({
cache: true, cache: true,
rateLimit: true, rateLimit: true,
jwksRequestsPerMinute: 5, jwksRequestsPerMinute: 5,
jwksUri: `${issuer.endsWith('/') ? issuer.slice(0, -1) : issuer}/jwks/`, jwksUri: optionen.jwksUri,
}), }),
}); });
if (!optionen.audience) {
new Logger(JwtStrategy.name).warn(
'OIDC_CLIENT_ID ist nicht gesetzt – die Audience von Tokens wird NICHT geprüft. ' +
'Tokens anderer Anwendungen desselben Authentik würden akzeptiert.',
);
}
} }
validate(payload: any): { validate(payload: any): {