diff --git a/docker-compose.yml b/docker-compose.yml index b641dee..beaea4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/paperless-backend/src/auth/jwt-optionen.spec.ts b/paperless-backend/src/auth/jwt-optionen.spec.ts new file mode 100644 index 0000000..9ea57d2 --- /dev/null +++ b/paperless-backend/src/auth/jwt-optionen.spec.ts @@ -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/); + }); +}); diff --git a/paperless-backend/src/auth/jwt-optionen.ts b/paperless-backend/src/auth/jwt-optionen.ts new file mode 100644 index 0000000..55c2034 --- /dev/null +++ b/paperless-backend/src/auth/jwt-optionen.ts @@ -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/`, + }; +} diff --git a/paperless-backend/src/auth/jwt.strategy.ts b/paperless-backend/src/auth/jwt.strategy.ts index 5c47830..4dc0ee4 100644 --- a/paperless-backend/src/auth/jwt.strategy.ts +++ b/paperless-backend/src/auth/jwt.strategy.ts @@ -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('OIDC_ISSUER', ''); + const optionen = jwtPruefOptionen( + configService.get('OIDC_ISSUER', ''), + configService.get('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): {