This commit is contained in:
Bastian Wagner
2026-07-20 17:01:16 +02:00
parent 2f84a109e8
commit aa7758c9dd
38 changed files with 1129 additions and 134 deletions

View File

@@ -9,6 +9,10 @@ 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';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserRoleAssignmentEntity } from '../users/entities/user-role-assignment.entity';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [
@@ -16,12 +20,20 @@ import { AuthService } from './auth.service';
OidcLoginStateEntity,
UserEntity,
UserSettingsEntity,
UserRoleAssignmentEntity,
]),
RolesModule,
SessionsModule,
AuditModule,
],
controllers: [AuthController],
providers: [AuthService, ExternalHttpClient, UsersRepository],
providers: [
AuthService,
ExternalHttpClient,
UsersRepository,
OidcRoleExtractorService,
OidcAdminSynchronizationService,
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, Repository } from 'typeorm';
import type { DataSource, EntityManager, 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';
@@ -7,8 +7,82 @@ 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';
import type { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserEntity } from '../users/entities/user.entity';
import { UserSettingsEntity } from '../users/entities/user-settings.entity';
import type { RoleEntity } from '../roles/entities/role.entity';
describe('AuthService', () => {
it('does not grant the first or second provisioned user the admin role', async () => {
let sequence = 0;
const userRole = {
id: 'role-user',
name: 'user',
roleKey: 'USER',
} as RoleEntity;
const adminRole = {
id: 'role-admin',
name: 'admin',
roleKey: 'ADMIN',
} as RoleEntity;
const manager = {
query: () => Promise.resolve(),
getRepository: (entity: unknown) => {
if (entity === UserEntity) {
return {
findOne: () => Promise.resolve(null),
save: (user: UserEntity) => {
user.id = `user-${++sequence}`;
return Promise.resolve(user);
},
};
}
if (entity === UserSettingsEntity) {
return {
findOne: () => Promise.resolve(null),
save: (settings: UserSettingsEntity) => Promise.resolve(settings),
};
}
throw new Error('Unerwartetes Repository');
},
} as unknown as EntityManager;
const service = new AuthService(
{} as AppConfigService,
{} as ExternalHttpClient,
{
ensureSystemRoles: () =>
Promise.resolve({ admin: adminRole, user: userRole }),
} as unknown as RolesService,
{} as UsersRepository,
{} as SessionsService,
{} as OidcAdminSynchronizationService,
{
transaction: <T>(
action: (transactionManager: EntityManager) => Promise<T>,
) => action(manager),
} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);
const provision = service as unknown as {
upsertLocalUser: (
issuer: string,
profile: { sub: string; name: string },
) => Promise<UserEntity>;
};
const first = await provision.upsertLocalUser('issuer', {
sub: 'first',
name: 'First',
});
const second = await provision.upsertLocalUser('issuer', {
sub: 'second',
name: 'Second',
});
expect(first.roles.map((role) => role.roleKey)).toEqual(['USER']);
expect(second.roles.map((role) => role.roleKey)).toEqual(['USER']);
});
it('stores only an internal return path in the short-lived OIDC login state', async () => {
const saved: OidcLoginStateEntity[] = [];
const service = new AuthService(
@@ -36,6 +110,7 @@ describe('AuthService', () => {
{} as RolesService,
{} as UsersRepository,
{} as SessionsService,
{} as OidcAdminSynchronizationService,
{} as DataSource,
{
delete: () => Promise.resolve({}),
@@ -76,6 +151,7 @@ describe('AuthService', () => {
{} as RolesService,
{} as UsersRepository,
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
{} as OidcAdminSynchronizationService,
{} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);

View File

@@ -17,6 +17,8 @@ import type {
OidcTokenResponse,
OidcUserInfo,
} from './oidc.types';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
@Injectable()
export class AuthService {
@@ -26,6 +28,7 @@ export class AuthService {
private readonly roles: RolesService,
private readonly users: UsersRepository,
private readonly sessions: SessionsService,
private readonly adminSynchronization: OidcAdminSynchronizationService,
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(OidcLoginStateEntity)
private readonly loginStates: Repository<OidcLoginStateEntity>,
@@ -83,12 +86,12 @@ export class AuthService {
code,
loginState.codeVerifier,
);
const profile = await this.verifyAndLoadProfile(
const { profile, claims } = await this.verifyAndLoadProfile(
discovery,
tokens,
loginState.nonce,
);
const user = await this.upsertLocalUser(discovery.issuer, profile);
let user = await this.upsertLocalUser(discovery.issuer, profile);
if (!user.active) {
throw new ApiError(
ErrorCode.UserDisabled,
@@ -96,6 +99,7 @@ export class AuthService {
403,
);
}
user = await this.adminSynchronization.synchronize(user.id, claims);
const result = await this.sessions.createSession(
user,
@@ -129,8 +133,7 @@ export class AuthService {
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);
const { user: userRole } = await this.roles.ensureSystemRoles(manager);
let user = await manager.getRepository(UserEntity).findOne({
where: { issuer, subject: profile.sub },
relations: { roles: true, settings: true },
@@ -141,10 +144,6 @@ export class AuthService {
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;
@@ -154,6 +153,12 @@ export class AuthService {
: null;
user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user);
await manager.query(
`INSERT IGNORE INTO user_role_assignments
(id, user_id, role_id, source, last_synchronized_at, created_at, updated_at)
VALUES (UUID(), ?, ?, ?, NULL, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))`,
[savedUser.id, userRole.id, UserRoleAssignmentSource.System],
);
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
return savedUser;
} finally {
@@ -266,7 +271,7 @@ export class AuthService {
discovery: OidcDiscovery,
tokens: OidcTokenResponse,
nonce: string,
): Promise<OidcUserInfo> {
): Promise<{ profile: OidcUserInfo; claims: Record<string, unknown> }> {
const { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } =
await import('jose');
const protectedHeader = decodeProtectedHeader(tokens.id_token);
@@ -305,7 +310,7 @@ export class AuthService {
if (typeof payload['email_verified'] === 'boolean') {
fallback.email_verified = payload['email_verified'];
}
return fallback;
return { profile: fallback, claims: { ...payload } };
}
const userInfo = await this.http.requestJson<OidcUserInfo>(
discovery.userinfo_endpoint,
@@ -335,7 +340,7 @@ export class AuthService {
) {
userInfo.email_verified = payload['email_verified'];
}
return userInfo;
return { profile: userInfo, claims: { ...payload, ...userInfo } };
}
private isAllowedOidcAlgorithm(algorithm: string | undefined): boolean {

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, EntityManager } from 'typeorm';
import type { AuditService } from '../audit/audit.service';
import type { AppConfigService } from '../config/config.service';
import type { RoleEntity } from '../roles/entities/role.entity';
import type { RolesService } from '../roles/roles.service';
import type { UserEntity } from '../users/entities/user.entity';
import { UserRoleAssignmentSource } from '../users/entities/user-role-assignment.entity';
import { OidcAdminSynchronizationService } from './oidc-admin-synchronization.service';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
function fixture(
existing: boolean,
remainingSources = 0,
adminRole: string | undefined = 'hauspilot-admin',
caseSensitive = true,
) {
const assignment = {
id: 'assignment-1',
userId: 'user-1',
roleId: 'admin-role',
source: UserRoleAssignmentSource.Oidc,
lastSynchronizedAt: new Date(),
};
const repository = {
findOneBy: vi.fn(() => Promise.resolve(existing ? assignment : null)),
save: vi.fn(() => Promise.resolve(assignment)),
insert: vi.fn(() =>
Promise.resolve({ identifiers: [], generatedMaps: [], raw: [] }),
),
remove: vi.fn(() => Promise.resolve(assignment)),
countBy: vi.fn(() => Promise.resolve(remainingSources)),
};
const query = vi.fn(() => Promise.resolve([]));
const manager = {
getRepository: () => repository,
query,
} as unknown as EntityManager;
const user = { id: 'user-1', roles: [] } as unknown as UserEntity;
const dataSource = {
transaction: (callback: (value: EntityManager) => Promise<void>) =>
callback(manager),
getRepository: () => ({ findOne: () => Promise.resolve(user) }),
} as unknown as DataSource;
const audit = {
record: vi.fn(() => Promise.resolve()),
} as unknown as AuditService;
const roles = {
ensureSystemRoles: () =>
Promise.resolve({
admin: { id: 'admin-role' } as RoleEntity,
user: {} as RoleEntity,
}),
} as RolesService;
const config = {
oidc: {
adminRole,
rolesClaim: 'realm_access.roles',
roleMatchCaseSensitive: caseSensitive,
},
} as AppConfigService;
return {
service: new OidcAdminSynchronizationService(
config,
new OidcRoleExtractorService(),
roles,
audit,
dataSource,
),
repository,
query,
audit,
};
}
describe('OidcAdminSynchronizationService', () => {
it('adds the OIDC source and effective admin role idempotently', async () => {
const first = fixture(false);
await first.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-admin'] },
});
expect(first.query).toHaveBeenCalledWith(
expect.stringContaining('ON DUPLICATE KEY UPDATE'),
['user-1', 'admin-role', UserRoleAssignmentSource.Oidc],
);
expect(first.query).toHaveBeenCalledWith(
'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)',
['user-1', 'admin-role'],
);
const repeated = fixture(true);
await repeated.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-admin'] },
});
expect(repeated.repository.save).toHaveBeenCalledOnce();
});
it('removes only the OIDC source while another source keeps the effective role', async () => {
const current = fixture(true, 1);
await current.service.synchronize('user-1', {
realm_access: { roles: ['hauspilot-user'] },
});
expect(current.repository.remove).toHaveBeenCalledOnce();
expect(current.query).not.toHaveBeenCalledWith(
'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?',
expect.anything(),
);
});
it('fails safely without changing assignments when the configured claim is missing', async () => {
const current = fixture(true);
await expect(
current.service.synchronize('user-1', { sub: '123' }),
).rejects.toMatchObject({
status: 401,
});
expect(current.repository.remove).not.toHaveBeenCalled();
expect(current.repository.insert).not.toHaveBeenCalled();
});
it('does not add or remove assignments when synchronization is disabled', async () => {
const current = fixture(true, 0, '');
await current.service.synchronize('user-1', {
realm_access: { roles: [] },
});
expect(current.repository.remove).not.toHaveBeenCalled();
expect(current.query).not.toHaveBeenCalled();
});
it('honors case-insensitive matching only when explicitly configured', async () => {
const current = fixture(false, 0, 'hauspilot-admin', false);
await current.service.synchronize('user-1', {
realm_access: { roles: ['HAUSPILOT-ADMIN'] },
});
expect(current.query).toHaveBeenCalledWith(
expect.stringContaining('ON DUPLICATE KEY UPDATE'),
['user-1', 'admin-role', UserRoleAssignmentSource.Oidc],
);
});
});

View File

@@ -0,0 +1,139 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
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 { UserEntity } from '../users/entities/user.entity';
import {
UserRoleAssignmentEntity,
UserRoleAssignmentSource,
} from '../users/entities/user-role-assignment.entity';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
@Injectable()
export class OidcAdminSynchronizationService {
private readonly logger = new Logger(OidcAdminSynchronizationService.name);
constructor(
private readonly config: AppConfigService,
private readonly extractor: OidcRoleExtractorService,
private readonly roles: RolesService,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async synchronize(
userId: string,
claims: Readonly<Record<string, unknown>>,
): Promise<UserEntity> {
const adminRoleName = this.config.oidc.adminRole;
if (!adminRoleName) return this.reloadUser(userId);
const claimPath = this.config.oidc.rolesClaim;
if (!claimPath)
throw new Error(
'OIDC_ROLES_CLAIM fehlt trotz validierter Konfiguration.',
);
const extracted = this.extractor.extract(claims, claimPath);
if (!extracted.readable) {
this.logger.error(
{ userId, claimPath, reason: extracted.reason },
'OIDC-Administrator-Synchronisierung: Rollenclaim konnte nicht gelesen werden',
);
throw new ApiError(
ErrorCode.Unauthorized,
'Die OIDC-Rollen konnten nicht sicher validiert werden.',
401,
);
}
const normalize = (value: string) =>
this.config.oidc.roleMatchCaseSensitive
? value
: value.toLocaleLowerCase('en-US');
const hasAdminRole = extracted.roles.some(
(role) => normalize(role) === normalize(adminRoleName),
);
await this.dataSource.transaction(async (manager) => {
const { admin } = await this.roles.ensureSystemRoles(manager);
const assignments = manager.getRepository(UserRoleAssignmentEntity);
const existing = await assignments.findOneBy({
userId,
roleId: admin.id,
source: UserRoleAssignmentSource.Oidc,
});
if (hasAdminRole) {
if (existing) {
existing.lastSynchronizedAt = new Date();
await assignments.save(existing);
} else {
await manager.query(
`INSERT INTO user_role_assignments
(id, user_id, role_id, source, last_synchronized_at, created_at, updated_at)
VALUES (UUID(), ?, ?, ?, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE last_synchronized_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)`,
[userId, admin.id, UserRoleAssignmentSource.Oidc],
);
await manager.query(
'INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, admin.id],
);
await this.audit.record(
userId,
AuditAction.OidcAdminAssigned,
'user',
userId,
{
roleId: admin.id,
oidcRole: adminRoleName,
source: UserRoleAssignmentSource.Oidc,
},
manager,
);
}
} else if (existing) {
await assignments.remove(existing);
const remaining = await assignments.countBy({
userId,
roleId: admin.id,
});
if (remaining === 0) {
await manager.query(
'DELETE FROM user_roles WHERE user_id = ? AND role_id = ?',
[userId, admin.id],
);
}
await this.audit.record(
userId,
AuditAction.OidcAdminRemoved,
'user',
userId,
{
roleId: admin.id,
oidcRole: adminRoleName,
source: UserRoleAssignmentSource.Oidc,
},
manager,
);
}
});
return this.reloadUser(userId);
}
private async reloadUser(userId: string): Promise<UserEntity> {
const user = await this.dataSource.getRepository(UserEntity).findOne({
where: { id: userId },
relations: { roles: { permissions: true }, settings: true },
});
if (!user)
throw new ApiError(
ErrorCode.UserNotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
return user;
}
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { OidcRoleExtractorService } from './oidc-role-extractor.service';
describe('OidcRoleExtractorService', () => {
const extractor = new OidcRoleExtractorService();
it('reads nested arrays, trims values and removes duplicates and empty roles', () => {
expect(
extractor.extract(
{
realm_access: {
roles: [
' hauspilot-admin ',
'hauspilot-user',
'',
'hauspilot-admin',
],
},
},
'realm_access.roles',
),
).toEqual({ readable: true, roles: ['hauspilot-admin', 'hauspilot-user'] });
});
it('accepts a single role string', () => {
expect(extractor.extract({ roles: 'hauspilot-admin' }, 'roles')).toEqual({
readable: true,
roles: ['hauspilot-admin'],
});
});
it('distinguishes a missing claim from a valid claim without the admin role', () => {
expect(extractor.extract({ sub: '123' }, 'roles')).toEqual({
readable: false,
reason: 'missing',
});
expect(extractor.extract({ roles: ['hauspilot-user'] }, 'roles')).toEqual({
readable: true,
roles: ['hauspilot-user'],
});
});
it('rejects mixed or object claim formats', () => {
expect(extractor.extract({ roles: ['valid', 42] }, 'roles')).toEqual({
readable: false,
reason: 'invalid',
});
});
});

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
export type OidcRoleExtractionResult =
| { readable: true; roles: string[] }
| { readable: false; reason: 'missing' | 'invalid' };
@Injectable()
export class OidcRoleExtractorService {
extract(
claims: Readonly<Record<string, unknown>>,
claimPath: string,
): OidcRoleExtractionResult {
let value: unknown = claims;
for (const segment of claimPath.split('.')) {
if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value) ||
!(segment in value)
) {
return { readable: false, reason: 'missing' };
}
value = (value as Record<string, unknown>)[segment];
}
const values: unknown[] =
typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
if (
(!Array.isArray(value) && typeof value !== 'string') ||
values.some((entry) => typeof entry !== 'string')
) {
return { readable: false, reason: 'invalid' };
}
const roles: string[] = [];
for (const entry of values) {
if (typeof entry !== 'string') continue;
const role = entry.trim();
if (role && !roles.includes(role)) roles.push(role);
}
return {
readable: true,
roles,
};
}
}