Initial commit

This commit is contained in:
2026-07-19 13:09:04 +02:00
commit 8cf57d7878
215 changed files with 30417 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import { Controller, Get, Query, Redirect, Req, Res } from '@nestjs/common';
import type { Response } from 'express';
import type { AuthenticatedRequest } from './authenticated-request';
import { Public } from './guards/public.decorator';
import { AppConfigService } from '../config/config.service';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly config: AppConfigService,
) {}
@Get('login')
@Public()
@SensitiveRateLimit()
@Redirect()
async login() {
return { url: await this.auth.createLoginUrl() };
}
@Get('callback')
@Public()
@SensitiveRateLimit()
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const { session, csrfToken } = await this.auth.completeLogin(
code,
state,
req.get('user-agent'),
req.ip,
);
res.cookie(this.config.session.cookieName, session.id, {
httpOnly: true,
signed: true,
secure: this.config.isProduction,
sameSite: 'lax',
path: '/',
expires: session.absoluteExpiresAt,
});
res.cookie('csrf_token', csrfToken, {
httpOnly: false,
secure: this.config.isProduction,
sameSite: 'lax',
path: '/',
expires: session.absoluteExpiresAt,
});
res.redirect(this.config.frontendBaseUrl);
}
@Get('logout')
@Public()
@SensitiveRateLimit()
async logout(@Req() req: AuthenticatedRequest, @Res() res: Response) {
const sessionId = req.signedCookies?.[this.config.session.cookieName] as
| string
| undefined;
const logoutUrl = await this.auth.logout(sessionId);
res.clearCookie(this.config.session.cookieName, { path: '/' });
res.clearCookie('csrf_token', { path: '/' });
res.redirect(logoutUrl);
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExternalHttpClient } from '../common/http/external-http-client';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
TypeOrmModule.forFeature([
OidcLoginStateEntity,
UserEntity,
UserSettingsEntity,
]),
RolesModule,
SessionsModule,
],
controllers: [AuthController],
providers: [AuthService, ExternalHttpClient, UsersRepository],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, Repository } from 'typeorm';
import type { ExternalHttpClient } from '../common/http/external-http-client';
import type { AppConfigService } from '../config/config.service';
import type { RolesService } from '../roles/roles.service';
import type { SessionsService } from '../sessions/sessions.service';
import type { UsersRepository } from '../users/repositories/users.repository';
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthService } from './auth.service';
describe('AuthService', () => {
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
Promise.resolve('id-token'),
);
const service = new AuthService(
{
frontendBaseUrl: 'https://app.example.test',
appBaseUrl: 'https://app.example.test',
oidc: {
issuer: 'https://idp.example.test',
clientId: 'business-app',
clientSecret: 'secret',
scopes: 'openid profile email',
allowedAlgorithms: ['RS256'],
httpTimeoutMs: 5000,
logoutUrl: 'https://idp.example.test/logout',
},
} as AppConfigService,
{} as ExternalHttpClient,
{} as RolesService,
{} as UsersRepository,
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
{} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);
const url = new URL(await service.logout('session-1'));
expect(revoke).toHaveBeenCalledWith('session-1');
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
expect(url.searchParams.get('client_id')).toBe('business-app');
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://app.example.test',
);
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
});
});

View File

@@ -0,0 +1,328 @@
import { createHash, randomBytes } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, LessThan, Repository } from 'typeorm';
import { ExternalHttpClient } from '../common/http/external-http-client';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { AppConfigService } from '../config/config.service';
import { RolesService } from '../roles/roles.service';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import { SessionsService } from '../sessions/sessions.service';
import { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import type {
OidcDiscovery,
OidcTokenResponse,
OidcUserInfo,
} from './oidc.types';
@Injectable()
export class AuthService {
constructor(
private readonly config: AppConfigService,
private readonly http: ExternalHttpClient,
private readonly roles: RolesService,
private readonly users: UsersRepository,
private readonly sessions: SessionsService,
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(OidcLoginStateEntity)
private readonly loginStates: Repository<OidcLoginStateEntity>,
) {}
async createLoginUrl(): Promise<string> {
const discovery = await this.discovery();
const state = randomBytes(32).toString('hex');
const nonce = randomBytes(32).toString('base64url');
const codeVerifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
await this.loginStates.delete({ expiresAt: LessThan(new Date()) });
const loginState = new OidcLoginStateEntity();
loginState.state = state;
loginState.codeVerifier = codeVerifier;
loginState.nonce = nonce;
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.loginStates.save(loginState);
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.config.oidc.clientId);
url.searchParams.set('redirect_uri', this.callbackUrl);
url.searchParams.set('scope', this.config.oidc.scopes);
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
return url.toString();
}
async completeLogin(
code: string,
state: string,
userAgent: string | undefined,
ip: string | undefined,
) {
const loginState = await this.loginStates.findOneBy({ state });
if (!loginState || loginState.expiresAt <= new Date()) {
throw new ApiError(
ErrorCode.Unauthorized,
'Die Anmeldung ist abgelaufen.',
401,
);
}
await this.loginStates.delete({ state });
const discovery = await this.discovery();
const tokens = await this.exchangeCode(
discovery,
code,
loginState.codeVerifier,
);
const profile = await this.verifyAndLoadProfile(
discovery,
tokens,
loginState.nonce,
);
const user = await this.upsertLocalUser(discovery.issuer, profile);
if (!user.active) {
throw new ApiError(
ErrorCode.UserDisabled,
'Dieser Benutzer ist deaktiviert.',
403,
);
}
return this.sessions.createSession(
user,
{
accessToken: tokens.access_token,
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
idToken: tokens.id_token,
accessTokenExpiresAt: new Date(
Date.now() + (tokens.expires_in ?? 3600) * 1000,
),
},
userAgent,
ip,
);
}
async logout(sessionId: string | undefined): Promise<string> {
let idToken: string | undefined;
if (sessionId) {
idToken = await this.readLogoutIdToken(sessionId);
await this.sessions.revoke(sessionId);
}
return this.createLogoutUrl(idToken);
}
private async upsertLocalUser(
issuer: string,
profile: OidcUserInfo,
): Promise<UserEntity> {
return this.dataSource.transaction(async (manager) => {
await manager.query("SELECT GET_LOCK('business_app_first_admin', 10)");
try {
const { admin, user: userRole } =
await this.roles.ensureSystemRoles(manager);
let user = await manager.getRepository(UserEntity).findOne({
where: { issuer, subject: profile.sub },
relations: { roles: true, settings: true },
});
if (!user) {
user = new UserEntity();
user.issuer = issuer;
user.subject = profile.sub;
user.active = true;
user.roles = [userRole];
const userCount = await manager.getRepository(UserEntity).count();
if (userCount === 0) {
user.roles = [userRole, admin];
}
}
user.name = profile.name ?? profile.email ?? profile.sub;
user.email = profile.email ?? null;
user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user);
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
return savedUser;
} finally {
await manager.query("SELECT RELEASE_LOCK('business_app_first_admin')");
}
});
}
private async ensureUserSettings(
manager: EntityManager,
user: UserEntity,
): Promise<UserSettingsEntity> {
const settingsRepository = manager.getRepository(UserSettingsEntity);
const existingSettings = await settingsRepository.findOne({
where: { user: { id: user.id } },
});
if (existingSettings) {
return existingSettings;
}
const settings = new UserSettingsEntity();
settings.user = user;
return settingsRepository.save(settings);
}
private async discovery(): Promise<OidcDiscovery> {
const discoveryUrl = new URL(
'/.well-known/openid-configuration',
this.config.oidc.issuer,
);
const discovery = await this.http.requestJson<OidcDiscovery>(
discoveryUrl.toString(),
);
if (discovery.issuer !== this.config.oidc.issuer) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Issuer ist ungueltig.',
401,
);
}
return discovery;
}
private async readLogoutIdToken(
sessionId: string,
): Promise<string | undefined> {
try {
return await this.sessions.getIdTokenForLogout(sessionId);
} catch {
return undefined;
}
}
private async createLogoutUrl(idToken: string | undefined): Promise<string> {
const endpoint = await this.resolveLogoutEndpoint();
if (!endpoint) {
return this.config.frontendBaseUrl;
}
const url = new URL(endpoint);
url.searchParams.set('client_id', this.config.oidc.clientId);
url.searchParams.set(
'post_logout_redirect_uri',
this.config.frontendBaseUrl,
);
if (idToken) {
url.searchParams.set('id_token_hint', idToken);
}
return url.toString();
}
private async resolveLogoutEndpoint(): Promise<string | undefined> {
if (this.config.oidc.logoutUrl) {
return this.config.oidc.logoutUrl;
}
try {
return (await this.discovery()).end_session_endpoint;
} catch {
return undefined;
}
}
private async exchangeCode(
discovery: OidcDiscovery,
code: string,
codeVerifier: string,
): Promise<OidcTokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.callbackUrl,
client_id: this.config.oidc.clientId,
code_verifier: codeVerifier,
});
const basic = Buffer.from(
`${this.config.oidc.clientId}:${this.config.oidc.clientSecret}`,
'utf8',
).toString('base64');
return this.http.requestJson<OidcTokenResponse>(discovery.token_endpoint, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
}
private async verifyAndLoadProfile(
discovery: OidcDiscovery,
tokens: OidcTokenResponse,
nonce: string,
): Promise<OidcUserInfo> {
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
await import('jose');
const protectedHeader = decodeProtectedHeader(tokens.id_token);
if (!this.isAllowedOidcAlgorithm(protectedHeader.alg)) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Signaturalgorithmus ist ungueltig.',
401,
);
}
const { payload } = await jwtVerify(
tokens.id_token,
createRemoteJWKSet(new URL(discovery.jwks_uri)),
{
issuer: discovery.issuer,
audience: this.config.oidc.clientId,
algorithms: this.config.oidc.allowedAlgorithms,
},
);
if (payload['nonce'] !== nonce || typeof payload.sub !== 'string') {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-Token ist ungueltig.',
401,
);
}
if (!discovery.userinfo_endpoint) {
const fallback: OidcUserInfo = { sub: payload.sub };
if (typeof payload['name'] === 'string') {
fallback.name = payload['name'];
}
if (typeof payload['email'] === 'string') {
fallback.email = payload['email'];
}
return fallback;
}
const userInfo = await this.http.requestJson<OidcUserInfo>(
discovery.userinfo_endpoint,
{
headers: { Authorization: `Bearer ${tokens.access_token}` },
},
);
if (userInfo.sub !== payload.sub) {
throw new ApiError(
ErrorCode.Unauthorized,
'OIDC-UserInfo ist ungueltig.',
401,
);
}
return userInfo;
}
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {
return (
typeof algorithm === 'string' &&
algorithm.toLowerCase() !== 'none' &&
this.config.oidc.allowedAlgorithms.includes(algorithm)
);
}
private get callbackUrl(): string {
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
}
}

View File

@@ -0,0 +1,12 @@
import type { Request } from 'express';
import type { Permission } from '../roles/permissions';
export interface AuthenticatedUser {
id: string;
sessionId: string;
permissions: Permission[];
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}

View File

@@ -0,0 +1,19 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('oidc_login_states')
export class OidcLoginStateEntity {
@PrimaryColumn({ type: 'char', length: 64 })
state!: string;
@Column({ name: 'code_verifier', type: 'varchar', length: 160 })
codeVerifier!: string;
@Column({ type: 'varchar', length: 160 })
nonce!: string;
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
expiresAt!: Date;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
}

View File

@@ -0,0 +1,49 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiError } from '../../common/errors/api-error';
import { ErrorCode } from '../../common/errors/error-codes';
import { AppConfigService } from '../../config/config.service';
import { SessionsService } from '../../sessions/sessions.service';
import { IS_PUBLIC_KEY } from './public.decorator';
import type { AuthenticatedRequest } from '../authenticated-request';
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
@Injectable()
export class CsrfGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionsService,
private readonly config: AppConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
if (!unsafeMethods.has(request.method)) {
return true;
}
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const sessionId = request.signedCookies?.[
this.config.session.cookieName
] as string | undefined;
const csrfToken = request.header(this.config.csrfHeaderName);
if (
!sessionId ||
!csrfToken ||
!(await this.sessions.verifyCsrfToken(sessionId, csrfToken))
) {
throw new ApiError(
ErrorCode.CsrfInvalid,
'Das Sicherheits-Token ist ungueltig.',
403,
);
}
return true;
}
}

View File

@@ -0,0 +1,68 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiError } from '../../common/errors/api-error';
import { ErrorCode } from '../../common/errors/error-codes';
import { AppConfigService } from '../../config/config.service';
import { SessionsService } from '../../sessions/sessions.service';
import type { Permission } from '../../roles/permissions';
import { IS_PUBLIC_KEY } from './public.decorator';
import { REQUIRED_PERMISSIONS_KEY } from './require-permissions.decorator';
import type { AuthenticatedRequest } from '../authenticated-request';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly sessions: SessionsService,
private readonly config: AppConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
const required = this.reflector.getAllAndOverride<Permission[]>(
REQUIRED_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (isPublic || !required?.length) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const sessionId = request.signedCookies?.[
this.config.session.cookieName
] as string | undefined;
if (!sessionId) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
const session = await this.sessions.resolveSession(
sessionId,
request.ip,
request.get('user-agent'),
);
request.user = {
id: session.user.id,
sessionId,
permissions: session.permissions,
};
const allowed = required.every((permission) =>
session.permissions.includes(permission),
);
if (!allowed) {
throw new ApiError(
ErrorCode.PermissionDenied,
'Keine Berechtigung fuer diese Aktion.',
403,
);
}
return true;
}
}

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import type { Permission } from '../../roles/permissions';
export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,23 @@
export interface OidcDiscovery {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint?: string;
jwks_uri: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
}
export interface OidcTokenResponse {
access_token: string;
refresh_token?: string;
id_token: string;
expires_in?: number;
token_type: string;
}
export interface OidcUserInfo {
sub: string;
name?: string;
email?: string;
}