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:
1 parent
b11294ec10
commit
c28475f71f
4 files changed
+92
-5
No files matched your search
@@ -23,6 +23,8 @@ services:
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llava}
|
||||
- SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive}
|
||||
- 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_PORT=${SMTP_PORT:-587}
|
||||
- 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/`,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { mapGroupsToPermissions } from './permissions.enum';
|
||||
import { jwtPruefOptionen } from './jwt-optionen';
|
||||
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,20 +13,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
configService: ConfigService,
|
||||
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({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
issuer,
|
||||
algorithms: ['RS256'],
|
||||
issuer: optionen.issuer,
|
||||
audience: optionen.audience,
|
||||
algorithms: optionen.algorithms,
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
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): {
|
||||
|
||||
Reference in new issue
Block a user