generated from bastian/boilerplate
deploy
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
140
apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts
Normal file
140
apps/backend/src/auth/oidc-admin-synchronization.service.spec.ts
Normal 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],
|
||||
);
|
||||
});
|
||||
});
|
||||
139
apps/backend/src/auth/oidc-admin-synchronization.service.ts
Normal file
139
apps/backend/src/auth/oidc-admin-synchronization.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
apps/backend/src/auth/oidc-role-extractor.service.spec.ts
Normal file
49
apps/backend/src/auth/oidc-role-extractor.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/backend/src/auth/oidc-role-extractor.service.ts
Normal file
44
apps/backend/src/auth/oidc-role-extractor.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user