mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: enhance OIDC flow with audit logs and improved error handling
- add audit events for OIDC login and user provisioning - improve callback error messages and state validation - add OIDC provider schema and initial migration
This commit is contained in:
@@ -25,6 +25,13 @@ APP_DATA_PATH=../local/data
|
||||
# OIDC (optional — configure via admin settings UI; these are just for reference)
|
||||
# OIDC config is stored in the database (app_settings table, key: oidc_config)
|
||||
|
||||
# OIDC runtime tuning (optional — uncomment to override defaults)
|
||||
# OIDC_STATE_TTL_SECS=600
|
||||
# OIDC_DISCOVERY_CACHE_TTL_SECS=3600
|
||||
# OIDC_JWKS_CACHE_TTL_SECS=3600
|
||||
# OIDC_CLOCK_TOLERANCE_SECS=30
|
||||
# OIDC_TOKEN_EXCHANGE_TIMEOUT_MS=10000
|
||||
|
||||
# App URL (used for building password reset links in emails)
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, storageConfig } from './config/config';
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, oidcRuntimeConfig, storageConfig } from './config/config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { loggerConfig } from './common/logger.config';
|
||||
import { CommonModule } from './common/common.module';
|
||||
@@ -59,7 +59,7 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, fileWriteConfig, emailConfig, migrationConfig],
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, fileWriteConfig, emailConfig, migrationConfig, oidcRuntimeConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRoot({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthorAutoEnrichmentConfig } from '@projectx/types';
|
||||
import type { AuthorAutoEnrichmentConfig, OidcBaseConfig } from '@projectx/types';
|
||||
|
||||
export const APP_SETTING_KEYS = {
|
||||
ALLOW_REGISTRATION: 'allow_registration',
|
||||
@@ -24,24 +24,8 @@ export const APP_SETTING_KEYS = {
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||
|
||||
export interface OidcFullConfig {
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
export interface OidcFullConfig extends OidcBaseConfig {
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
claimMapping: {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string;
|
||||
};
|
||||
autoProvision: {
|
||||
enabled: boolean;
|
||||
allowLocalLinking: boolean;
|
||||
defaultPermissionNames: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_OIDC_CONFIG: OidcFullConfig = {
|
||||
|
||||
@@ -33,6 +33,14 @@ export const migrationConfig = registerAs('migration', () => ({
|
||||
encryptionKey: process.env.MIGRATION_ENCRYPTION_KEY ?? '',
|
||||
}));
|
||||
|
||||
export const oidcRuntimeConfig = registerAs('oidcRuntime', () => ({
|
||||
stateTtlMs: parsePositiveInteger(process.env.OIDC_STATE_TTL_SECS, 300) * 1000,
|
||||
discoveryCacheTtlMs: parsePositiveInteger(process.env.OIDC_DISCOVERY_CACHE_TTL_SECS, 3600) * 1000,
|
||||
jwksCacheTtlMs: parsePositiveInteger(process.env.OIDC_JWKS_CACHE_TTL_SECS, 21600) * 1000,
|
||||
clockToleranceSecs: parsePositiveInteger(process.env.OIDC_CLOCK_TOLERANCE_SECS, 30),
|
||||
tokenExchangeTimeoutMs: parsePositiveInteger(process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS, 10_000),
|
||||
}));
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
|
||||
@@ -573,11 +573,23 @@ CREATE TABLE "oidc_sessions" (
|
||||
"oidc_issuer" text NOT NULL,
|
||||
"oidc_session_id" text,
|
||||
"id_token_hint" text,
|
||||
"idp_refresh_token" text,
|
||||
"revoked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_states" (
|
||||
"state" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"meta" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_used_jtis" (
|
||||
"jti" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "opds_users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
@@ -983,6 +995,8 @@ CREATE INDEX "oidc_sessions_user_active_idx" ON "oidc_sessions" USING btree ("us
|
||||
CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_expires_at_idx" ON "oidc_sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_states_expires_at_idx" ON "oidc_states" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_used_jtis_expires_at_idx" ON "oidc_used_jtis" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "opds_users_username_lower_uidx" ON "opds_users" USING btree (lower("username"));--> statement-breakpoint
|
||||
CREATE INDEX "kobo_devices_user_id_idx" ON "kobo_devices" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "kobo_snapshot_books_snapshot_synced_book_idx" ON "kobo_snapshot_books" USING btree ("snapshot_id","synced","book_id");--> statement-breakpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "7d462c3d-9524-40b8-95e1-afda1b364a68",
|
||||
"id": "373242c5-f28a-4dd1-a03f-e6b0abec26c9",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -5348,6 +5348,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"idp_refresh_token": {
|
||||
"name": "idp_refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked": {
|
||||
"name": "revoked",
|
||||
"type": "boolean",
|
||||
@@ -5455,6 +5461,94 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_states": {
|
||||
"name": "oidc_states",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"meta": {
|
||||
"name": "meta",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_states_expires_at_idx": {
|
||||
"name": "oidc_states_expires_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_used_jtis": {
|
||||
"name": "oidc_used_jtis",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"jti": {
|
||||
"name": "jti",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_used_jtis_expires_at_idx": {
|
||||
"name": "oidc_used_jtis_expires_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.opds_users": {
|
||||
"name": "opds_users",
|
||||
"schema": "",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1776080409147,
|
||||
"when": 1776150932210,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -3,6 +3,25 @@ import { boolean, index, integer, pgTable, serial, text, timestamp, varchar } fr
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const oidcStates = pgTable(
|
||||
'oidc_states',
|
||||
{
|
||||
state: text('state').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
meta: text('meta'),
|
||||
},
|
||||
(t) => [index('oidc_states_expires_at_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const oidcUsedJtis = pgTable(
|
||||
'oidc_used_jtis',
|
||||
{
|
||||
jti: text('jti').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [index('oidc_used_jtis_expires_at_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const oidcSessions = pgTable(
|
||||
'oidc_sessions',
|
||||
{
|
||||
@@ -14,6 +33,7 @@ export const oidcSessions = pgTable(
|
||||
oidcIssuer: text('oidc_issuer').notNull(),
|
||||
oidcSessionId: text('oidc_session_id'),
|
||||
idTokenHint: text('id_token_hint'),
|
||||
idpRefreshToken: text('idp_refresh_token'),
|
||||
revoked: boolean('revoked').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AppSettingsController } from './app-settings.controller';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
|
||||
function makeService(): jest.Mocked<AppSettingsService> {
|
||||
return {
|
||||
@@ -25,6 +26,15 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
||||
} as unknown as jest.Mocked<AppSettingsService>;
|
||||
}
|
||||
|
||||
function makeGroupMappingService(): jest.Mocked<OidcGroupMappingAdminService> {
|
||||
return {
|
||||
listMappings: vi.fn().mockResolvedValue([]),
|
||||
createMapping: vi.fn(),
|
||||
updateMapping: vi.fn(),
|
||||
deleteMapping: vi.fn(),
|
||||
} as unknown as jest.Mocked<OidcGroupMappingAdminService>;
|
||||
}
|
||||
|
||||
describe('AppSettingsController', () => {
|
||||
let controller: AppSettingsController;
|
||||
let service: ReturnType<typeof makeService>;
|
||||
@@ -33,7 +43,10 @@ describe('AppSettingsController', () => {
|
||||
service = makeService();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [{ provide: AppSettingsService, useValue: service }],
|
||||
providers: [
|
||||
{ provide: AppSettingsService, useValue: service },
|
||||
{ provide: OidcGroupMappingAdminService, useValue: makeGroupMappingService() },
|
||||
],
|
||||
}).compile();
|
||||
controller = module.get(AppSettingsController);
|
||||
});
|
||||
@@ -93,6 +106,7 @@ describe('AppSettingsController', () => {
|
||||
clientId: 'projectx',
|
||||
clientSecret: 'supersecret',
|
||||
scopes: 'openid',
|
||||
iconUrl: 'https://kc.example.com/icon.png',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
});
|
||||
@@ -101,6 +115,7 @@ describe('AppSettingsController', () => {
|
||||
expect(result).not.toHaveProperty('claimMapping');
|
||||
expect(result).not.toHaveProperty('autoProvision');
|
||||
expect(result.clientId).toBe('projectx');
|
||||
expect(result.iconUrl).toBe('https://kc.example.com/icon.png');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
|
||||
import { Permission, AuditAction, AuditResource } from '@projectx/types';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Auditable } from '../../common/decorators/auditable.decorator';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
||||
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
|
||||
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
|
||||
import { CreateGroupMappingDto } from './dto/create-group-mapping.dto';
|
||||
import { UpdateGroupMappingDto } from './dto/update-group-mapping.dto';
|
||||
|
||||
@Controller('app-settings')
|
||||
@RequirePermission(Permission.ManageAppSettings)
|
||||
export class AppSettingsController {
|
||||
constructor(private readonly appSettingsService: AppSettingsService) {}
|
||||
constructor(
|
||||
private readonly appSettingsService: AppSettingsService,
|
||||
private readonly groupMappingAdmin: OidcGroupMappingAdminService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
listSettings() {
|
||||
@@ -66,6 +72,7 @@ export class AppSettingsController {
|
||||
issuerUri: config.issuerUri,
|
||||
clientId: config.clientId,
|
||||
scopes: config.scopes,
|
||||
iconUrl: config.iconUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,4 +93,27 @@ export class AppSettingsController {
|
||||
testOidcConnection(@Query('issuerUri') issuerUri?: string) {
|
||||
return this.appSettingsService.testOidcConnection(issuerUri);
|
||||
}
|
||||
|
||||
@Get('oidc/group-mappings')
|
||||
listGroupMappings() {
|
||||
return this.groupMappingAdmin.listMappings();
|
||||
}
|
||||
|
||||
@Post('oidc/group-mappings')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createGroupMapping(@Body() dto: CreateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.createMapping(dto.oidcGroupClaim, dto.permissionName);
|
||||
}
|
||||
|
||||
@Put('oidc/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateGroupMapping(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.updateMapping(id, dto.permissionName);
|
||||
}
|
||||
|
||||
@Delete('oidc/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteGroupMapping(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.groupMappingAdmin.deleteMapping(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Module } from '@nestjs/common';
|
||||
import { AppSettingsController } from './app-settings.controller';
|
||||
import { AppSettingsRepository } from './app-settings.repository';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [AppSettingsRepository, AppSettingsService],
|
||||
providers: [AppSettingsRepository, AppSettingsService, OidcGroupMappingAdminService],
|
||||
exports: [AppSettingsService],
|
||||
})
|
||||
export class AppSettingsModule {}
|
||||
|
||||
@@ -104,7 +104,19 @@ export class AppSettingsService {
|
||||
return merged;
|
||||
}
|
||||
|
||||
async testOidcConnection(issuerUri?: string): Promise<{ success: boolean; issuer?: string; authorizationEndpoint?: string; error?: string }> {
|
||||
async testOidcConnection(issuerUri?: string): Promise<{
|
||||
success: boolean;
|
||||
issuer?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userinfoEndpoint?: string;
|
||||
jwksUri?: string;
|
||||
supportedScopes?: string[];
|
||||
supportedGrantTypes?: string[];
|
||||
codeChallengeMethodsSupported?: string[];
|
||||
backchannelLogoutSupported?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const uri = issuerUri || (await this.getOidcConfig()).issuerUri;
|
||||
if (!uri) {
|
||||
throw new BadRequestException('Issuer URI is not configured');
|
||||
@@ -128,7 +140,18 @@ export class AppSettingsService {
|
||||
throw new BadRequestException('Provider returned an invalid discovery document');
|
||||
}
|
||||
this.logger.log(`[app-settings.oidc_test] [end] issuerUri=${uri} durationMs=${Date.now() - start} - OIDC connection test succeeded`);
|
||||
return { success: true, issuer: json.issuer, authorizationEndpoint: json.authorization_endpoint };
|
||||
return {
|
||||
success: true,
|
||||
issuer: json.issuer,
|
||||
authorizationEndpoint: json.authorization_endpoint,
|
||||
tokenEndpoint: (json as Record<string, unknown>).token_endpoint as string | undefined,
|
||||
userinfoEndpoint: (json as Record<string, unknown>).userinfo_endpoint as string | undefined,
|
||||
jwksUri: (json as Record<string, unknown>).jwks_uri as string | undefined,
|
||||
supportedScopes: (json as Record<string, unknown>).scopes_supported as string[] | undefined,
|
||||
supportedGrantTypes: (json as Record<string, unknown>).grant_types_supported as string[] | undefined,
|
||||
codeChallengeMethodsSupported: (json as Record<string, unknown>).code_challenge_methods_supported as string[] | undefined,
|
||||
backchannelLogoutSupported: (json as Record<string, unknown>).backchannel_logout_supported as boolean | undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - start;
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsString, MaxLength } from 'class-validator';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
export class CreateGroupMappingDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
oidcGroupClaim: string;
|
||||
|
||||
@IsIn(Object.values(Permission))
|
||||
permissionName: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsIn } from 'class-validator';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
export class UpdateGroupMappingDto {
|
||||
@IsIn(Object.values(Permission))
|
||||
permissionName: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator';
|
||||
|
||||
class ClaimMappingDto {
|
||||
@IsString()
|
||||
@@ -43,7 +43,7 @@ export class UpdateOidcConfigDto {
|
||||
providerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
issuerUri?: string;
|
||||
|
||||
@@ -62,6 +62,11 @@ export class UpdateOidcConfigDto {
|
||||
@MaxLength(500)
|
||||
scopes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClaimMappingDto)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
query: {
|
||||
oidcGroupMappings: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OidcGroupMappingAdminService', () => {
|
||||
let service: OidcGroupMappingAdminService;
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new OidcGroupMappingAdminService(db as never);
|
||||
});
|
||||
|
||||
describe('listMappings', () => {
|
||||
it('returns all mappings ordered by oidcGroupClaim', async () => {
|
||||
const rows = [{ id: 1, oidcGroupClaim: 'admins', permissionName: 'manage_users' }];
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue(rows);
|
||||
await expect(service.listMappings()).resolves.toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMapping', () => {
|
||||
it('inserts and returns the new row', async () => {
|
||||
const row = { id: 1, oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
const result = await service.createMapping('editors', 'library_edit_metadata');
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
expect(db.values).toHaveBeenCalledWith({ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' });
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMapping', () => {
|
||||
it('updates permissionName and returns the updated row', async () => {
|
||||
const row = { id: 5, oidcGroupClaim: 'editors', permissionName: 'manage_users' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
const result = await service.updateMapping(5, 'manage_users');
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(db.set).toHaveBeenCalledWith({ permissionName: 'manage_users' });
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when row does not exist', async () => {
|
||||
db.returning.mockResolvedValue([]);
|
||||
await expect(service.updateMapping(99, 'manage_users')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMapping', () => {
|
||||
it('deletes the row when found', async () => {
|
||||
const row = { id: 3, oidcGroupClaim: 'admins', permissionName: 'manage_users' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
await expect(service.deleteMapping(3)).resolves.toBeUndefined();
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when row does not exist', async () => {
|
||||
db.returning.mockResolvedValue([]);
|
||||
await expect(service.deleteMapping(99)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcGroupMappingAdminService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
listMappings() {
|
||||
return this.db.query.oidcGroupMappings.findMany({ orderBy: (t, { asc }) => [asc(t.oidcGroupClaim)] });
|
||||
}
|
||||
|
||||
async createMapping(oidcGroupClaim: string, permissionName: string) {
|
||||
const [row] = await this.db.insert(schema.oidcGroupMappings).values({ oidcGroupClaim, permissionName }).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateMapping(id: number, permissionName: string) {
|
||||
const [row] = await this.db.update(schema.oidcGroupMappings).set({ permissionName }).where(eq(schema.oidcGroupMappings.id, id)).returning();
|
||||
if (!row) throw new NotFoundException(`Group mapping ${id} not found`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async deleteMapping(id: number) {
|
||||
const [row] = await this.db.delete(schema.oidcGroupMappings).where(eq(schema.oidcGroupMappings.id, id)).returning();
|
||||
if (!row) throw new NotFoundException(`Group mapping ${id} not found`);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/auth/auth.service.ts',
|
||||
'src/modules/auth/oidc/backchannel-logout.service.ts',
|
||||
'src/modules/auth/oidc/oidc-group-mapping.service.ts',
|
||||
'src/modules/auth/oidc/oidc-state.service.ts',
|
||||
'src/modules/authors/author-enrichment-config.service.ts',
|
||||
'src/modules/book/book-query-builder.service.ts',
|
||||
'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts',
|
||||
@@ -66,6 +67,8 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/book-bucket/book-bucket.service.ts',
|
||||
'src/modules/upload/upload-processor.service.ts',
|
||||
'src/modules/upload/upload.service.ts',
|
||||
'src/modules/app-settings/oidc-group-mapping-admin.service.ts',
|
||||
'src/modules/auth/oidc/oidc-cleanup.service.ts',
|
||||
].sort();
|
||||
|
||||
const allFiles = await listFilesRecursive(modulesRoot);
|
||||
|
||||
@@ -19,8 +19,12 @@ function makeController() {
|
||||
};
|
||||
const oidcService = {
|
||||
generateState: vi.fn(),
|
||||
generateLinkState: vi.fn(),
|
||||
generatePreviewState: vi.fn(),
|
||||
handleCallback: vi.fn(),
|
||||
handleBackchannelLogout: vi.fn(),
|
||||
getLinkedIdentity: vi.fn(),
|
||||
unlinkIdentity: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new AuthController(authService as never, oidcService as never);
|
||||
@@ -63,16 +67,28 @@ describe('AuthController', () => {
|
||||
it('delegates oidc endpoints to OidcService', async () => {
|
||||
const { controller, oidcService } = makeController();
|
||||
const reply = {} as never;
|
||||
oidcService.generateState.mockReturnValue('state-123');
|
||||
const stateResult = { state: 'state-123', authorizationEndpoint: 'https://idp.example.com/auth' };
|
||||
oidcService.generateState.mockResolvedValue(stateResult);
|
||||
oidcService.generateLinkState.mockResolvedValue(stateResult);
|
||||
oidcService.generatePreviewState.mockResolvedValue(stateResult);
|
||||
oidcService.getLinkedIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://idp.example.com' });
|
||||
|
||||
expect(controller.oidcGenerateState()).toEqual({ state: 'state-123' });
|
||||
await expect(controller.oidcGenerateState()).resolves.toEqual(stateResult);
|
||||
await controller.oidcCallback({ code: 'abc' } as never, reply);
|
||||
await controller.oidcBackchannelLogout({ body: { logout_token: 'logout-1' } } as never);
|
||||
await controller.oidcBackchannelLogout({ body: undefined } as never);
|
||||
await controller.oidcGenerateLinkState({ id: 7 } as never);
|
||||
await controller.oidcGeneratePreviewState();
|
||||
await controller.oidcGetIdentity({ id: 7 } as never);
|
||||
await controller.oidcUnlinkIdentity({ id: 7 } as never, { password: 'Secret1!' });
|
||||
|
||||
expect(oidcService.handleCallback).toHaveBeenCalledWith({ code: 'abc' }, reply);
|
||||
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(1, 'logout-1');
|
||||
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(2, '');
|
||||
expect(oidcService.generateLinkState).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.generatePreviewState).toHaveBeenCalled();
|
||||
expect(oidcService.getLinkedIdentity).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 'Secret1!');
|
||||
});
|
||||
|
||||
it('defines throttling metadata for sensitive public endpoints', () => {
|
||||
@@ -100,5 +116,15 @@ describe('AuthController', () => {
|
||||
expect(forgotTtl).toBe(3_600_000);
|
||||
expect(resetLimit).toBe(5);
|
||||
expect(resetTtl).toBe(60_000);
|
||||
|
||||
const oidcStateLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcGenerateState) as number;
|
||||
const oidcStateTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcGenerateState) as number;
|
||||
const oidcCallbackLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcCallback) as number;
|
||||
const oidcCallbackTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcCallback) as number;
|
||||
|
||||
expect(oidcStateLimit).toBe(10);
|
||||
expect(oidcStateTtl).toBe(60_000);
|
||||
expect(oidcCallbackLimit).toBe(5);
|
||||
expect(oidcCallbackTtl).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OidcCallbackDto } from './dto/oidc-callback.dto';
|
||||
import { OidcUnlinkDto } from './dto/oidc-unlink.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { SetupDto } from './dto/setup.dto';
|
||||
@@ -123,13 +124,15 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateState() {
|
||||
return { state: this.oidcService.generateState() };
|
||||
return this.oidcService.generateState();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/callback')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcCallback(@Body() dto: OidcCallbackDto, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
@@ -144,4 +147,29 @@ export class AuthController {
|
||||
const logoutToken = body?.['logout_token'] ?? '';
|
||||
return this.oidcService.handleBackchannelLogout(logoutToken);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/link-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateLinkState(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.generateLinkState(user.id);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/preview-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGeneratePreviewState() {
|
||||
return this.oidcService.generatePreviewState();
|
||||
}
|
||||
|
||||
@Get('oidc/identity')
|
||||
oidcGetIdentity(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.getLinkedIdentity(user.id);
|
||||
}
|
||||
|
||||
@Delete('oidc/identity')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Body() dto: OidcUnlinkDto) {
|
||||
return this.oidcService.unlinkIdentity(user.id, dto.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { BackchannelLogoutService } from './oidc/backchannel-logout.service';
|
||||
import { OidcClaimExtractorService } from './oidc/oidc-claim-extractor.service';
|
||||
import { OidcCleanupService } from './oidc/oidc-cleanup.service';
|
||||
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
|
||||
import { OidcGroupMappingService } from './oidc/oidc-group-mapping.service';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
@@ -51,6 +52,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
|
||||
OidcSessionRepository,
|
||||
OidcGroupMappingService,
|
||||
BackchannelLogoutService,
|
||||
OidcCleanupService,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class OidcUnlinkDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
}
|
||||
@@ -13,12 +13,26 @@ const DISCOVERY_DOC = {
|
||||
backchannelLogoutSupported: true,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
const db = {
|
||||
function makeDb() {
|
||||
return {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ jti: 'jti-1' }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
const db = { ...makeDb(), ...dbOverrides };
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({ enabled: true, issuerUri: 'https://idp.example.com', clientId: 'client-id' }),
|
||||
};
|
||||
@@ -103,18 +117,6 @@ describe('BackchannelLogoutService', () => {
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents JTI replay — ignores second call with same jti', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'replay-jti', exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('token-1');
|
||||
await service.handleLogout('token-2');
|
||||
|
||||
// incrementTokenVersion should only be called once (second call is a replay)
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proceeds normally when jti is absent', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1' });
|
||||
@@ -123,4 +125,76 @@ describe('BackchannelLogoutService', () => {
|
||||
await service.handleLogout('token-no-jti');
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
describe('JTI replay prevention (DB-backed)', () => {
|
||||
it('rejects second call when DB insert returns empty (conflict = already used)', async () => {
|
||||
const jtiInsertResult: { jti: string }[] = [];
|
||||
const db = makeDb();
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue(jtiInsertResult),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService(db);
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({
|
||||
sub: 'u1',
|
||||
jti: 'replay-jti',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('token-1');
|
||||
|
||||
// incrementTokenVersion should NOT be called because insert returned empty (replay)
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes successfully when DB insert returns the new row (first use)', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({
|
||||
sub: 'u1',
|
||||
jti: 'fresh-jti',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('fresh-token');
|
||||
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('stores JTI with expiry derived from exp claim', async () => {
|
||||
const valuesMock = vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ jti: 'jti-exp' }]),
|
||||
}),
|
||||
});
|
||||
const db = makeDb();
|
||||
db.insert.mockReturnValue({ values: valuesMock });
|
||||
|
||||
const { service, tokenValidator, sessionRepo } = makeService(db);
|
||||
const exp = Math.floor(Date.now() / 1000) + 7200;
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'jti-exp', exp });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 5 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
|
||||
const [[{ jti, expiresAt }]] = valuesMock.mock.calls;
|
||||
expect(jti).toBe('jti-exp');
|
||||
expect(expiresAt.getTime()).toBeCloseTo(exp * 1000, -3);
|
||||
});
|
||||
|
||||
it('prunes expired JTIs from DB after each logout', async () => {
|
||||
const { service, tokenValidator, sessionRepo, db } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'prune-jti' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 3 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { and, eq, isNull, lt } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -15,8 +15,6 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
@Injectable()
|
||||
export class BackchannelLogoutService {
|
||||
private readonly logger = new Logger(BackchannelLogoutService.name);
|
||||
// JTI replay prevention: jti -> expiry timestamp
|
||||
private readonly usedJtis = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@@ -39,24 +37,23 @@ export class BackchannelLogoutService {
|
||||
jwksUri: discovery.jwksUri,
|
||||
});
|
||||
|
||||
// Replay prevention
|
||||
// Replay prevention via DB — atomic insert, conflict = already used
|
||||
if (claims.jti) {
|
||||
const jti = typeof claims.jti === 'string' ? claims.jti : undefined;
|
||||
if (jti && this.usedJtis.has(jti)) {
|
||||
this.logger.warn(
|
||||
'[auth.oidc_backchannel_logout] [fail] errorClass=UnauthorizedException error="logout token jti replay" - backchannel logout rejected',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exp = claims.exp ? claims.exp * 1000 : Date.now() + 3_600_000;
|
||||
if (jti) {
|
||||
this.usedJtis.set(jti, exp);
|
||||
}
|
||||
const expiresAt = claims.exp ? new Date(claims.exp * 1000) : new Date(Date.now() + 3_600_000);
|
||||
|
||||
// Prune expired JTIs
|
||||
const now = Date.now();
|
||||
for (const [jti, expiry] of this.usedJtis) {
|
||||
if (expiry < now) this.usedJtis.delete(jti);
|
||||
const inserted = await this.db.insert(schema.oidcUsedJtis).values({ jti, expiresAt }).onConflictDoNothing().returning();
|
||||
|
||||
if (inserted.length === 0) {
|
||||
this.logger.warn(
|
||||
'[auth.oidc_backchannel_logout] [fail] errorClass=UnauthorizedException error="logout token jti replay" - backchannel logout rejected',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune expired JTIs
|
||||
await this.db.delete(schema.oidcUsedJtis).where(lt(schema.oidcUsedJtis.expiresAt, new Date()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ExtractedClaims {
|
||||
subject: string;
|
||||
@@ -18,15 +18,36 @@ export interface ClaimMapping {
|
||||
|
||||
@Injectable()
|
||||
export class OidcClaimExtractorService {
|
||||
private readonly logger = new Logger(OidcClaimExtractorService.name);
|
||||
|
||||
extract(idTokenClaims: Record<string, unknown>, userInfoClaims: Record<string, unknown>, mapping: ClaimMapping): ExtractedClaims {
|
||||
// userInfo claims take precedence over ID token claims
|
||||
const merged = { ...idTokenClaims, ...userInfoClaims };
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const subject = str(merged.sub);
|
||||
const username = str(merged[mapping.username]) || str(merged.email) || str(merged.sub);
|
||||
const name = str(merged[mapping.name]) || str(merged.name) || username;
|
||||
|
||||
const rawUsername = str(merged[mapping.username]);
|
||||
const username = rawUsername || str(merged.email) || str(merged.sub);
|
||||
if (!rawUsername && username) {
|
||||
this.logger.warn(
|
||||
`[auth.oidc_claim_extract] configured username claim "${mapping.username}" missing; fell back to "${!str(merged.email) ? 'sub' : 'email'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawName = str(merged[mapping.name]);
|
||||
const name = rawName || str(merged.name) || username;
|
||||
if (!rawName && name) {
|
||||
this.logger.warn(
|
||||
`[auth.oidc_claim_extract] configured name claim "${mapping.name}" missing; fell back to "${!str(merged.name) ? 'username' : 'name'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const email = typeof merged[mapping.email] === 'string' ? (merged[mapping.email] as string) : undefined;
|
||||
if (!email && mapping.email) {
|
||||
this.logger.warn(`[auth.oidc_claim_extract] configured email claim "${mapping.email}" missing or not a string`);
|
||||
}
|
||||
|
||||
const avatarUrl = typeof merged.picture === 'string' ? merged.picture : undefined;
|
||||
|
||||
const rawGroups = merged[mapping.groups];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { OidcCleanupService } from './oidc-cleanup.service';
|
||||
|
||||
function makeDb() {
|
||||
const chain = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
return {
|
||||
delete: vi.fn().mockReturnValue(chain),
|
||||
_chain: chain,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OidcCleanupService', () => {
|
||||
let service: OidcCleanupService;
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new OidcCleanupService(db as never);
|
||||
});
|
||||
|
||||
it('deletes expired/revoked sessions, expired states, and expired JTIs in parallel', async () => {
|
||||
db._chain.returning
|
||||
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) // sessions
|
||||
.mockResolvedValueOnce([{ state: 'abc' }]) // states
|
||||
.mockResolvedValueOnce([]); // jtis
|
||||
|
||||
await service.runCleanup();
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('logs deletion counts on success', async () => {
|
||||
db._chain.returning
|
||||
.mockResolvedValueOnce([{ id: 1 }])
|
||||
.mockResolvedValueOnce([{ state: 'xyz' }])
|
||||
.mockResolvedValueOnce([{ jti: 'jti-1' }, { jti: 'jti-2' }]);
|
||||
|
||||
const logSpy = vi.spyOn(service['logger'], 'log').mockImplementation(() => undefined);
|
||||
|
||||
await service.runCleanup();
|
||||
|
||||
const endLog = logSpy.mock.calls.find((args) => String(args[0]).includes('[end]'));
|
||||
expect(endLog).toBeTruthy();
|
||||
expect(String(endLog![0])).toMatch('deletedSessions=1');
|
||||
expect(String(endLog![0])).toMatch('deletedStates=1');
|
||||
expect(String(endLog![0])).toMatch('deletedJtis=2');
|
||||
});
|
||||
|
||||
it('logs error and does not throw when cleanup fails', async () => {
|
||||
db._chain.returning.mockRejectedValueOnce(new Error('DB connection lost'));
|
||||
|
||||
const errorSpy = vi.spyOn(service['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.runCleanup()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching('[fail]'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { lt, or, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcCleanupService {
|
||||
private readonly logger = new Logger(OidcCleanupService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async runCleanup(): Promise<void> {
|
||||
const start = Date.now();
|
||||
this.logger.log('[auth.oidc_cleanup] [start] - OIDC cleanup started');
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const [sessions, states, jtis] = await Promise.all([
|
||||
this.db
|
||||
.delete(schema.oidcSessions)
|
||||
.where(or(lt(schema.oidcSessions.expiresAt, now), eq(schema.oidcSessions.revoked, true)))
|
||||
.returning({ id: schema.oidcSessions.id }),
|
||||
this.db.delete(schema.oidcStates).where(lt(schema.oidcStates.expiresAt, now)).returning({ state: schema.oidcStates.state }),
|
||||
this.db.delete(schema.oidcUsedJtis).where(lt(schema.oidcUsedJtis.expiresAt, now)).returning({ jti: schema.oidcUsedJtis.jti }),
|
||||
]);
|
||||
|
||||
this.logger.log(
|
||||
`[auth.oidc_cleanup] [end] durationMs=${Date.now() - start} deletedSessions=${sessions.length} deletedStates=${states.length} deletedJtis=${jtis.length} - OIDC cleanup completed`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`[auth.oidc_cleanup] [fail] durationMs=${Date.now() - start} errorClass=${errorClass} error="${errorMsg}" - OIDC cleanup failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../../common/utils/ssrf.utils', () => ({
|
||||
ensureSafeUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { ensureSafeUrl } from '../../../common/utils/ssrf.utils';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
|
||||
const RAW_DOC = {
|
||||
@@ -12,15 +17,24 @@ const RAW_DOC = {
|
||||
backchannel_logout_supported: true,
|
||||
};
|
||||
|
||||
function makeService(nodeEnv = 'development') {
|
||||
const configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'app.nodeEnv') return nodeEnv;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
return new OidcDiscoveryService(configService as never);
|
||||
}
|
||||
|
||||
describe('OidcDiscoveryService', () => {
|
||||
let service: OidcDiscoveryService;
|
||||
let fetchMock: vi.Mock;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcDiscoveryService();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(ensureSafeUrl).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,6 +51,7 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('fetches and normalizes the discovery document', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
|
||||
const doc = await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(doc.issuer).toBe('https://idp.example.com');
|
||||
@@ -50,12 +65,14 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('strips trailing slash from issuerUri before fetching', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com/');
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://idp.example.com/.well-known/openid-configuration', expect.anything());
|
||||
});
|
||||
|
||||
it('returns cached result on second call within TTL', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -63,6 +80,7 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('re-fetches after TTL expires', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
@@ -71,11 +89,13 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('throws InternalServerErrorException on non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('throws InternalServerErrorException on network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network failure'));
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
@@ -87,9 +107,122 @@ describe('OidcDiscoveryService', () => {
|
||||
jwks_uri: 'https://idp.example.com/jwks',
|
||||
};
|
||||
mockFetchSuccess(minimalDoc as never);
|
||||
const service = makeService();
|
||||
const doc = await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(doc.userinfoEndpoint).toBeUndefined();
|
||||
expect(doc.endSessionEndpoint).toBeUndefined();
|
||||
expect(doc.backchannelLogoutSupported).toBe(false);
|
||||
});
|
||||
|
||||
it('uses redirect:error to prevent redirect-based SSRF', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ redirect: 'error' }));
|
||||
});
|
||||
|
||||
describe('P0-3: issuer validation', () => {
|
||||
it('throws when discovery doc issuer does not match requested URI', async () => {
|
||||
const mismatchedDoc = { ...RAW_DOC, issuer: 'https://attacker.example.com' };
|
||||
mockFetchSuccess(mismatchedDoc);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('accepts issuer with trailing slash on either side', async () => {
|
||||
const docWithSlash = { ...RAW_DOC, issuer: 'https://idp.example.com/' };
|
||||
mockFetchSuccess(docWithSlash);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com/')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts exact match between issuerUri and discovery doc issuer', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts issuer with sub-path (e.g. Keycloak realm)', async () => {
|
||||
const keycloakDoc = {
|
||||
...RAW_DOC,
|
||||
issuer: 'http://localhost:8080/realms/projectx',
|
||||
authorization_endpoint: 'http://localhost:8080/realms/projectx/auth',
|
||||
token_endpoint: 'http://localhost:8080/realms/projectx/token',
|
||||
jwks_uri: 'http://localhost:8080/realms/projectx/jwks',
|
||||
userinfo_endpoint: undefined,
|
||||
end_session_endpoint: undefined,
|
||||
};
|
||||
mockFetchSuccess(keycloakDoc as never);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('http://localhost:8080/realms/projectx')).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('P0-4: endpoint SSRF validation', () => {
|
||||
it('calls ensureSafeUrl on token_endpoint', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/token', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on jwks_uri', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/jwks', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on userinfo_endpoint when present', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/userinfo', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on end_session_endpoint when present', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/logout', expect.any(Object));
|
||||
});
|
||||
|
||||
it('skips ensureSafeUrl for optional endpoints when absent', async () => {
|
||||
const minimalDoc = {
|
||||
issuer: 'https://idp.example.com',
|
||||
authorization_endpoint: 'https://idp.example.com/auth',
|
||||
token_endpoint: 'https://idp.example.com/token',
|
||||
jwks_uri: 'https://idp.example.com/jwks',
|
||||
};
|
||||
mockFetchSuccess(minimalDoc as never);
|
||||
const service = makeService();
|
||||
vi.mocked(ensureSafeUrl).mockClear();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
// Only token + jwks called, not userinfo or end_session
|
||||
const calls = vi.mocked(ensureSafeUrl).mock.calls.map(([url]) => url);
|
||||
expect(calls).not.toContain('https://idp.example.com/userinfo');
|
||||
expect(calls).not.toContain('https://idp.example.com/logout');
|
||||
});
|
||||
|
||||
it('throws InternalServerErrorException when an endpoint fails SSRF check', async () => {
|
||||
mockFetchSuccess();
|
||||
vi.mocked(ensureSafeUrl).mockRejectedValueOnce(new BadRequestException('URL host is not allowed'));
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('passes allowLocal=true in non-production environments', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService('development');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: true });
|
||||
});
|
||||
|
||||
it('passes allowLocal=false in production environment', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService('production');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { ensureSafeUrl } from '../../../common/utils/ssrf.utils';
|
||||
|
||||
export interface OidcDiscoveryDoc {
|
||||
issuer: string;
|
||||
@@ -29,7 +32,13 @@ interface RawDiscoveryDoc {
|
||||
export class OidcDiscoveryService {
|
||||
private readonly logger = new Logger(OidcDiscoveryService.name);
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
private readonly TTL = 60 * 60 * 1000; // 1 hour
|
||||
private readonly TTL: number;
|
||||
private readonly allowLocal: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.allowLocal = this.configService.get<string>('app.nodeEnv') !== 'production';
|
||||
this.TTL = this.configService.get<number>('oidcRuntime.discoveryCacheTtlMs') ?? 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
async getDiscoveryDoc(issuerUri: string): Promise<OidcDiscoveryDoc> {
|
||||
const normalized = issuerUri.replace(/\/$/, '');
|
||||
@@ -42,10 +51,25 @@ export class OidcDiscoveryService {
|
||||
const url = `${normalized}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'error' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = (await res.json()) as RawDiscoveryDoc;
|
||||
|
||||
// P0-3: Validate issuer matches the URI we requested
|
||||
const rawIssuer = (raw.issuer ?? '').replace(/\/$/, '');
|
||||
if (rawIssuer !== normalized) {
|
||||
throw new Error(`Issuer mismatch: expected "${normalized}", got "${raw.issuer}"`);
|
||||
}
|
||||
|
||||
// P0-4: Validate all endpoint URLs are SSRF-safe
|
||||
const ssrfOpts = { allowLocal: this.allowLocal };
|
||||
await Promise.all([
|
||||
ensureSafeUrl(raw.token_endpoint, ssrfOpts),
|
||||
ensureSafeUrl(raw.jwks_uri, ssrfOpts),
|
||||
raw.userinfo_endpoint ? ensureSafeUrl(raw.userinfo_endpoint, ssrfOpts) : Promise.resolve(),
|
||||
raw.end_session_endpoint ? ensureSafeUrl(raw.end_session_endpoint, ssrfOpts) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
const doc: OidcDiscoveryDoc = {
|
||||
issuer: raw.issuer,
|
||||
authorizationEndpoint: raw.authorization_endpoint,
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { OidcGroupMappingService } from './oidc-group-mapping.service';
|
||||
|
||||
function makeDb() {
|
||||
function makeTx() {
|
||||
return {
|
||||
query: {
|
||||
oidcGroupMappings: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
oidcGroupMappings: { findMany: vi.fn() },
|
||||
userPermissions: { findMany: vi.fn() },
|
||||
},
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
const tx = makeTx();
|
||||
return {
|
||||
tx,
|
||||
transaction: vi.fn().mockImplementation(async (cb: (tx: unknown) => Promise<void>) => cb(tx)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,60 +31,81 @@ describe('OidcGroupMappingService', () => {
|
||||
service = new OidcGroupMappingService(db as never);
|
||||
});
|
||||
|
||||
it('does nothing when groups array is empty', async () => {
|
||||
it('does nothing when no system mappings exist', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([]);
|
||||
await service.syncUserGroups(1, []);
|
||||
expect(db.query.oidcGroupMappings.findMany).not.toHaveBeenCalled();
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts permission assignments for matched groups', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(2);
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'manage_users' });
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'library_edit_metadata' });
|
||||
});
|
||||
|
||||
it('deduplicates permissionNames when multiple group claims map to the same permission', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'superadmins', permissionName: 'manage_users' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'superadmins']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when no mappings match the provided groups', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['unknown-group']);
|
||||
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips mappings with null permissionName', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: null },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'library_edit_metadata' });
|
||||
});
|
||||
|
||||
it('uses onConflictDoNothing to avoid duplicate permission assignments', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
it('adds desired permissions and skips existing ones', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
|
||||
expect(db.onConflictDoNothing).toHaveBeenCalled();
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes stale OIDC-managed permissions when user no longer has matching groups', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, []);
|
||||
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not remove permissions not in oidc_group_mappings (manually-granted)', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([
|
||||
{ userId: 42, permissionName: 'manage_users' },
|
||||
{ userId: 42, permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, []);
|
||||
|
||||
// manage_users is OIDC-managed and should be removed; library_edit_metadata is not
|
||||
expect(db.tx.delete).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates when multiple group claims map to the same permission', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'superadmins', permissionName: 'manage_users' },
|
||||
]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'superadmins']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
});
|
||||
|
||||
it('skips mappings with null permissionName', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: null },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'library_edit_metadata' }]);
|
||||
});
|
||||
|
||||
it('does not insert permissions user already has', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -13,20 +13,58 @@ export class OidcGroupMappingService {
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Full-sync OIDC group permissions for a user.
|
||||
*
|
||||
* Adds permissions gained from the current group claims and removes permissions
|
||||
* that were previously granted via OIDC groups but are no longer in scope.
|
||||
*
|
||||
* Note: Any permission that exists in `oidc_group_mappings` is treated as
|
||||
* OIDC-managed. If an admin also manually granted the same permission, OIDC
|
||||
* management takes precedence — the permission will be removed if the user's
|
||||
* groups no longer entitle it.
|
||||
*/
|
||||
async syncUserGroups(userId: number, groups: string[]): Promise<void> {
|
||||
if (groups.length === 0) return;
|
||||
const start = Date.now();
|
||||
this.logger.log(`[auth.oidc_group_sync] [start] userId=${userId} groupCount=${groups.length} - group sync started`);
|
||||
|
||||
const mappings = await this.db.query.oidcGroupMappings.findMany({
|
||||
where: inArray(schema.oidcGroupMappings.oidcGroupClaim, groups),
|
||||
await this.db.transaction(async (tx) => {
|
||||
// All group mappings in the system — defines the OIDC-managed permission set.
|
||||
const allMappings = await tx.query.oidcGroupMappings.findMany();
|
||||
if (allMappings.length === 0) return;
|
||||
|
||||
const allOidcPermissions = new Set(allMappings.map((m) => m.permissionName).filter(Boolean) as string[]);
|
||||
|
||||
// Desired permissions based on the user's current groups.
|
||||
const desired = new Set(
|
||||
allMappings.filter((m) => m.permissionName && groups.includes(m.oidcGroupClaim)).map((m) => m.permissionName as string),
|
||||
);
|
||||
|
||||
// Current user permissions that are OIDC-managed.
|
||||
const currentUserPerms = await tx.query.userPermissions.findMany({
|
||||
where: eq(schema.userPermissions.userId, userId),
|
||||
});
|
||||
const currentOidcPerms = currentUserPerms.filter((p) => allOidcPermissions.has(p.permissionName)).map((p) => p.permissionName);
|
||||
|
||||
const toAdd = [...desired].filter((p) => !currentUserPerms.some((up) => up.permissionName === p));
|
||||
const toRemove = currentOidcPerms.filter((p) => !desired.has(p));
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await tx
|
||||
.insert(schema.userPermissions)
|
||||
.values(toAdd.map((permissionName) => ({ userId, permissionName })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await tx
|
||||
.delete(schema.userPermissions)
|
||||
.where(and(eq(schema.userPermissions.userId, userId), inArray(schema.userPermissions.permissionName, toRemove)));
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[auth.oidc_group_sync] [end] userId=${userId} durationMs=${Date.now() - start} added=${toAdd.length} removed=${toRemove.length} - group sync completed`,
|
||||
);
|
||||
});
|
||||
|
||||
const permissionNames = [...new Set(mappings.flatMap((m) => (m.permissionName ? [m.permissionName] : [])))];
|
||||
if (permissionNames.length === 0) return;
|
||||
|
||||
for (const permissionName of permissionNames) {
|
||||
await this.db.insert(schema.userPermissions).values({ userId, permissionName }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
this.logger.debug(`Synced ${permissionNames.length} permission(s) for user ${userId} from OIDC groups`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,168 @@
|
||||
import { OidcStateService } from './oidc-state.service';
|
||||
|
||||
const mockConfig = { get: vi.fn().mockReturnValue(undefined) };
|
||||
|
||||
function makeDb(overrides: Partial<ReturnType<typeof makeDb>> = {}) {
|
||||
const deleteMock = vi.fn().mockReturnThis();
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: deleteMock }),
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||||
...overrides,
|
||||
};
|
||||
return { db, deleteMock };
|
||||
}
|
||||
|
||||
describe('OidcStateService', () => {
|
||||
let service: OidcStateService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcStateService();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('returns a non-empty base64url string', () => {
|
||||
const state = service.generate();
|
||||
it('returns a non-empty base64url string', async () => {
|
||||
const { db } = makeDb();
|
||||
db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const state = await service.generate();
|
||||
expect(typeof state).toBe('string');
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('returns unique values on each call', () => {
|
||||
const a = service.generate();
|
||||
const b = service.generate();
|
||||
it('returns unique values on each call', async () => {
|
||||
const { db } = makeDb();
|
||||
db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const a = await service.generate();
|
||||
const b = await service.generate();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('prunes expired states on generate', () => {
|
||||
const state = service.generate();
|
||||
// Advance past TTL (5 minutes)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
it('inserts state with meta=null when no meta passed', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
// generate() triggers pruning
|
||||
service.generate();
|
||||
const state = await service.generate();
|
||||
|
||||
// The original state should now be expired and validateAndConsume returns false
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
expect(insertMock).toHaveBeenCalledOnce();
|
||||
const [[{ state: insertedState, meta }]] = valuesMock.mock.calls;
|
||||
expect(insertedState).toBe(state);
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('inserts state with serialized meta when meta passed', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
const metaPayload = { mode: 'link', userId: 42 };
|
||||
|
||||
await service.generate(metaPayload);
|
||||
|
||||
const [[{ meta }]] = valuesMock.mock.calls;
|
||||
expect(JSON.parse(meta as string)).toEqual(metaPayload);
|
||||
});
|
||||
|
||||
it('inserts state into the database with an expiry', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const before = Date.now();
|
||||
const state = await service.generate();
|
||||
const after = Date.now();
|
||||
|
||||
const [[{ state: insertedState, expiresAt }]] = valuesMock.mock.calls;
|
||||
expect(insertedState).toBe(state);
|
||||
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||
expect(expiresAt.getTime()).toBeLessThanOrEqual(after + 20 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('prunes expired states on each generate call', async () => {
|
||||
const whereMock = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: whereMock }),
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
await service.generate();
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
expect(whereMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndConsume', () => {
|
||||
it('returns true for a valid, fresh state', () => {
|
||||
const state = service.generate();
|
||||
expect(service.validateAndConsume(state)).toBe(true);
|
||||
it('returns { valid: true } when delete removes a row (valid state, no meta)', async () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: null }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('valid-state');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns false on second use (one-time use)', () => {
|
||||
const state = service.generate();
|
||||
service.validateAndConsume(state);
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
it('returns { valid: true, meta } when row has serialized meta', async () => {
|
||||
const metaPayload = { mode: 'link', userId: 99 };
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: JSON.stringify(metaPayload) }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('valid-state');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.meta).toEqual(metaPayload);
|
||||
});
|
||||
|
||||
it('returns false for unknown state', () => {
|
||||
expect(service.validateAndConsume('totally-fake-state')).toBe(false);
|
||||
it('returns { valid: false } when delete removes nothing (expired or unknown state)', async () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('unknown-state');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for expired state', () => {
|
||||
const state = service.generate();
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
});
|
||||
it('uses DELETE...RETURNING for atomic one-time consumption', async () => {
|
||||
const returningMock = vi.fn().mockResolvedValue([]);
|
||||
const whereMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const deleteMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
const db = { delete: deleteMock, insert: vi.fn() };
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
it('returns true at exactly TTL boundary', () => {
|
||||
const state = service.generate();
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
// At exactly TTL, Date.now() - ts === TTL, which is NOT > TTL, so still valid
|
||||
expect(service.validateAndConsume(state)).toBe(true);
|
||||
await service.validateAndConsume('some-state');
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledOnce();
|
||||
expect(whereMock).toHaveBeenCalledOnce();
|
||||
expect(returningMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { and, gt, lt, eq } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcStateService {
|
||||
private readonly states = new Map<string, number>();
|
||||
private readonly TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly ttlMs: number;
|
||||
|
||||
generate(): string {
|
||||
// Prune expired states
|
||||
const now = Date.now();
|
||||
for (const [state, ts] of this.states) {
|
||||
if (now - ts > this.TTL) this.states.delete(state);
|
||||
}
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.ttlMs = this.configService.get<number>('oidcRuntime.stateTtlMs') ?? 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
async generate(meta?: Record<string, unknown>): Promise<string> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
this.states.set(state, Date.now());
|
||||
const expiresAt = new Date(Date.now() + this.ttlMs);
|
||||
|
||||
await Promise.all([
|
||||
this.db.delete(schema.oidcStates).where(lt(schema.oidcStates.expiresAt, new Date())),
|
||||
this.db.insert(schema.oidcStates).values({ state, expiresAt, meta: meta ? JSON.stringify(meta) : null }),
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
validateAndConsume(state: string): boolean {
|
||||
const ts = this.states.get(state);
|
||||
if (!ts) return false;
|
||||
this.states.delete(state); // one-time use
|
||||
return Date.now() - ts <= this.TTL;
|
||||
async validateAndConsume(state: string): Promise<{ valid: boolean; meta?: Record<string, unknown> }> {
|
||||
const deleted = await this.db
|
||||
.delete(schema.oidcStates)
|
||||
.where(and(eq(schema.oidcStates.state, state), gt(schema.oidcStates.expiresAt, new Date())))
|
||||
.returning();
|
||||
|
||||
if (deleted.length === 0) return { valid: false };
|
||||
const row = deleted[0];
|
||||
const meta = row.meta ? (JSON.parse(row.meta) as Record<string, unknown>) : undefined;
|
||||
return { valid: true, meta };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OidcTokenClientService', () => {
|
||||
let fetchMock: vi.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcTokenClientService();
|
||||
service = new OidcTokenClientService({ get: vi.fn().mockReturnValue(undefined) } as never);
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
@@ -17,6 +18,11 @@ interface TokenEndpointResponse {
|
||||
@Injectable()
|
||||
export class OidcTokenClientService {
|
||||
private readonly logger = new Logger(OidcTokenClientService.name);
|
||||
private readonly tokenExchangeTimeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.tokenExchangeTimeoutMs = this.configService.get<number>('oidcRuntime.tokenExchangeTimeoutMs') ?? 10_000;
|
||||
}
|
||||
|
||||
async exchangeCode(params: {
|
||||
code: string;
|
||||
@@ -44,7 +50,7 @@ export class OidcTokenClientService {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(this.tokenExchangeTimeoutMs),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('OidcTokenValidatorService', () => {
|
||||
let service: OidcTokenValidatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcTokenValidatorService();
|
||||
service = new OidcTokenValidatorService({ get: vi.fn().mockReturnValue(undefined) } as never);
|
||||
vi.clearAllMocks();
|
||||
(createRemoteJWKSet as vi.Mock).mockReturnValue('mocked-jwks');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
type JWKSGetter = ReturnType<typeof createRemoteJWKSet>;
|
||||
@@ -12,7 +13,13 @@ interface JWKSCacheEntry {
|
||||
export class OidcTokenValidatorService {
|
||||
private readonly logger = new Logger(OidcTokenValidatorService.name);
|
||||
private readonly jwksCache = new Map<string, JWKSCacheEntry>();
|
||||
private readonly JWKS_TTL = 6 * 3600 * 1000; // 6 hours
|
||||
private readonly JWKS_TTL: number;
|
||||
private readonly clockTolerance: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.JWKS_TTL = this.configService.get<number>('oidcRuntime.jwksCacheTtlMs') ?? 6 * 3600 * 1000;
|
||||
this.clockTolerance = this.configService.get<number>('oidcRuntime.clockToleranceSecs') ?? 30;
|
||||
}
|
||||
|
||||
private getJwks(jwksUri: string): JWKSGetter {
|
||||
const cached = this.jwksCache.get(jwksUri);
|
||||
@@ -30,7 +37,7 @@ export class OidcTokenValidatorService {
|
||||
const { payload } = await jwtVerify(idToken, jwks, {
|
||||
issuer: opts.issuer,
|
||||
audience: opts.clientId,
|
||||
clockTolerance: 30,
|
||||
clockTolerance: this.clockTolerance,
|
||||
});
|
||||
|
||||
if (payload.nonce !== opts.nonce) {
|
||||
@@ -53,7 +60,7 @@ export class OidcTokenValidatorService {
|
||||
const { payload } = await jwtVerify(logoutToken, jwks, {
|
||||
issuer: opts.issuer,
|
||||
audience: opts.clientId,
|
||||
clockTolerance: 30,
|
||||
clockTolerance: this.clockTolerance,
|
||||
});
|
||||
|
||||
// Logout tokens must have the backchannel-logout event
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
const APP_URL = 'http://localhost:5173';
|
||||
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
|
||||
|
||||
function makeService() {
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({
|
||||
@@ -29,11 +32,11 @@ function makeService() {
|
||||
validateIdToken: vi.fn().mockResolvedValue({ sub: 'sub-1' }),
|
||||
};
|
||||
const claimExtractor = {
|
||||
extract: vi.fn(),
|
||||
extract: vi.fn().mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] }),
|
||||
};
|
||||
const stateService = {
|
||||
generate: vi.fn(),
|
||||
validateAndConsume: vi.fn().mockReturnValue(true),
|
||||
generate: vi.fn().mockResolvedValue('state-token'),
|
||||
validateAndConsume: vi.fn().mockResolvedValue({ valid: true }),
|
||||
};
|
||||
const sessionRepo = {
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -48,12 +51,24 @@ function makeService() {
|
||||
findByOidcSubject: vi.fn(),
|
||||
findByUsername: vi.fn(),
|
||||
linkOidcIdentity: vi.fn(),
|
||||
unlinkOidcIdentity: vi.fn(),
|
||||
getUserOidcIdentity: vi.fn(),
|
||||
findPasswordHashById: vi.fn(),
|
||||
createOidcUser: vi.fn(),
|
||||
setPermissionsDirectly: vi.fn(),
|
||||
};
|
||||
const authService = {
|
||||
getRefreshTokenExpiryDate: vi.fn().mockReturnValue(new Date('2026-01-08T00:00:00Z')),
|
||||
issueTokensForUser: vi.fn(),
|
||||
issueTokensForUser: vi.fn().mockResolvedValue({ accessToken: 'token', user: {} }),
|
||||
};
|
||||
const auditEvents = {
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'app.appUrl') return APP_URL;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new OidcService(
|
||||
@@ -68,79 +83,212 @@ function makeService() {
|
||||
backchannelLogout as never,
|
||||
userService as never,
|
||||
authService as never,
|
||||
auditEvents as never,
|
||||
configService as never,
|
||||
);
|
||||
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo };
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo, stateService, auditEvents, discovery, tokenValidator };
|
||||
}
|
||||
|
||||
const BASE_CALLBACK = {
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: VALID_REDIRECT_URI,
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
};
|
||||
|
||||
describe('OidcService', () => {
|
||||
it('rejects callback when extracted subject is missing', async () => {
|
||||
const { service, claimExtractor } = makeService();
|
||||
claimExtractor.extract.mockReturnValue({
|
||||
subject: '',
|
||||
username: 'u1',
|
||||
name: 'User One',
|
||||
email: 'u1@example.com',
|
||||
groups: [],
|
||||
describe('generateState', () => {
|
||||
it('throws when OIDC is disabled', async () => {
|
||||
const { service, appSettings } = makeService();
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
await expect(service.generateState()).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleCallback(
|
||||
{
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
},
|
||||
{ setCookie: vi.fn() } as never,
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
it('returns state and authorizationEndpoint', async () => {
|
||||
const { service } = makeService();
|
||||
const result = await service.generateState();
|
||||
expect(result).toMatchObject({ state: 'state-token', authorizationEndpoint: 'https://issuer.example/auth' });
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses an OIDC user when auto-provision races on the new unique constraint', async () => {
|
||||
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
|
||||
const existingUser = { id: 7, active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: ['library_download'] },
|
||||
describe('generateLinkState', () => {
|
||||
it('generates state with link mode meta', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.generate.mockResolvedValue('link-state');
|
||||
const result = await service.generateLinkState(42);
|
||||
expect(stateService.generate).toHaveBeenCalledWith({ mode: 'link', userId: 42 });
|
||||
expect(result.state).toBe('link-state');
|
||||
});
|
||||
claimExtractor.extract.mockReturnValue({
|
||||
subject: 'sub-1',
|
||||
username: 'u1',
|
||||
name: 'User One',
|
||||
email: 'u1@example.com',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
describe('generatePreviewState', () => {
|
||||
it('generates state with preview mode meta', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.generate.mockResolvedValue('preview-state');
|
||||
const result = await service.generatePreviewState();
|
||||
expect(stateService.generate).toHaveBeenCalledWith({ mode: 'preview' });
|
||||
expect(result.state).toBe('preview-state');
|
||||
});
|
||||
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
|
||||
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token' });
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleCallback(
|
||||
{
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
},
|
||||
{ setCookie: vi.fn() } as never,
|
||||
),
|
||||
).resolves.toEqual({ accessToken: 'token' });
|
||||
describe('handleCallback', () => {
|
||||
it('rejects callback when state is invalid', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
|
||||
expect(sessionRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
oidcSubject: 'sub-1',
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
expiresAt: new Date('2026-01-08T00:00:00Z'),
|
||||
}),
|
||||
);
|
||||
it('rejects callback when redirectUri does not match allowed app URL', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.handleCallback({ ...BASE_CALLBACK, redirectUri: 'https://evil.example/callback' }, {} as never)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects callback when extracted subject is missing', async () => {
|
||||
const { service, claimExtractor } = makeService();
|
||||
claimExtractor.extract.mockReturnValue({ subject: '', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns login mode response for existing OIDC user', async () => {
|
||||
const { service, userService, authService } = makeService();
|
||||
const user = { id: 5, username: 'u1', active: true };
|
||||
userService.findByOidcSubject.mockResolvedValue(user);
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'at', user: { id: 5 } });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'login', accessToken: 'at' });
|
||||
});
|
||||
|
||||
it('emits OidcLogin audit event on successful login', async () => {
|
||||
const { service, userService, auditEvents } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue({ id: 5, username: 'u1', active: true });
|
||||
|
||||
await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.login' }));
|
||||
});
|
||||
|
||||
it('returns preview mode response with raw+mapped claims', async () => {
|
||||
const { service, stateService, claimExtractor, tokenValidator } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, meta: { mode: 'preview' } });
|
||||
tokenValidator.validateIdToken.mockResolvedValue({ sub: 'sub-1', email: 'u1@example.com' });
|
||||
claimExtractor.extract.mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: ['g1'] });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'preview', claims: { mapped: { username: 'u1', email: 'u1@example.com' } } });
|
||||
});
|
||||
|
||||
it('returns link mode response and emits OidcIdentityLinked', async () => {
|
||||
const { service, stateService, userService, auditEvents } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, meta: { mode: 'link', userId: 42 } });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'link', linked: true });
|
||||
expect(userService.linkOidcIdentity).toHaveBeenCalledWith(42, 'sub-1', 'https://issuer.example', undefined);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.identity_linked' }));
|
||||
});
|
||||
|
||||
it('rejects inactive user', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue({ id: 5, username: 'u1', active: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('throws when OIDC is disabled', async () => {
|
||||
const { service, appSettings } = makeService();
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('auto-provisions a new user and emits OidcUserProvisioned', async () => {
|
||||
const { service, userService, appSettings, auditEvents } = makeService();
|
||||
const newUser = { id: 9, username: 'u1', active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: [] },
|
||||
});
|
||||
userService.findByOidcSubject.mockResolvedValue(null);
|
||||
userService.createOidcUser.mockResolvedValue(newUser);
|
||||
|
||||
await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.user_provisioned' }));
|
||||
});
|
||||
|
||||
it('reuses an OIDC user when auto-provision races on unique constraint', async () => {
|
||||
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
|
||||
const existingUser = { id: 7, username: 'u1', active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: ['library_download'] },
|
||||
});
|
||||
claimExtractor.extract.mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] });
|
||||
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
|
||||
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token', user: {} });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'login', accessToken: 'token' });
|
||||
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
|
||||
expect(sessionRepo.create).toHaveBeenCalledWith(expect.objectContaining({ userId: 7, oidcSubject: 'sub-1' }));
|
||||
});
|
||||
|
||||
it('throws USER_NOT_PROVISIONED when no user and auto-provision is off', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue(null);
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIdentity', () => {
|
||||
it('delegates to userService.getUserOidcIdentity', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.getUserOidcIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
const result = await service.getLinkedIdentity(5);
|
||||
expect(userService.getUserOidcIdentity).toHaveBeenCalledWith(5);
|
||||
expect(result).toEqual({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkIdentity', () => {
|
||||
it('throws BadRequestException when password is incorrect', async () => {
|
||||
const { service, userService } = makeService();
|
||||
// Hash of 'correct-password' would be stored; we supply a bcrypt hash of something else
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash('correct-password', 4);
|
||||
userService.findPasswordHashById.mockResolvedValue(hash);
|
||||
|
||||
await expect(service.unlinkIdentity(5, 'wrong-password')).rejects.toThrow(BadRequestException);
|
||||
expect(userService.unlinkOidcIdentity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when user not found', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findPasswordHashById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlinkIdentity(99, 'any-password')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('unlinks identity on correct password and emits OidcIdentityUnlinked', async () => {
|
||||
const { service, userService, auditEvents } = makeService();
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash('correct-password', 4);
|
||||
userService.findPasswordHashById.mockResolvedValue(hash);
|
||||
userService.getUserOidcIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
|
||||
await service.unlinkIdentity(5, 'correct-password');
|
||||
expect(userService.unlinkOidcIdentity).toHaveBeenCalledWith(5);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.identity_unlinked' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { compare } from 'bcryptjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { Permission } from '@projectx/types';
|
||||
import { AuditAction, AuditResource, OidcCallbackResponse, OidcErrorCode, Permission } from '@projectx/types';
|
||||
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../../audit/audit-events.service';
|
||||
import { AppSettingsService } from '../../app-settings/app-settings.service';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
@@ -16,10 +19,8 @@ import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const directCode = (error as { code?: unknown }).code;
|
||||
if (directCode === '23505') return true;
|
||||
|
||||
if (!(error instanceof Error)) return false;
|
||||
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
|
||||
return causeCode === '23505';
|
||||
@@ -29,9 +30,19 @@ function toThrowable(error: unknown, fallbackMessage: string): Error {
|
||||
return error instanceof Error ? error : new UnauthorizedException(fallbackMessage);
|
||||
}
|
||||
|
||||
function normalizeRedirectUri(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.origin + u.pathname;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly logger = new Logger(OidcService.name);
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly appSettings: AppSettingsService,
|
||||
@@ -45,34 +56,100 @@ export class OidcService {
|
||||
private readonly backchannelLogout: BackchannelLogoutService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
generateState(): string {
|
||||
return this.stateService.generate();
|
||||
async generateState(): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate(), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
async generateLinkState(userId: number): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate({ mode: 'link', userId }), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
async generatePreviewState(): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate({ mode: 'preview' }), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
handleBackchannelLogout(logoutToken: string): Promise<void> {
|
||||
return this.backchannelLogout.handleLogout(logoutToken);
|
||||
}
|
||||
|
||||
async handleCallback(params: { code: string; codeVerifier: string; redirectUri: string; nonce: string; state: string }, reply: FastifyReply) {
|
||||
if (!this.stateService.validateAndConsume(params.state)) {
|
||||
throw new UnauthorizedException('Invalid or expired state');
|
||||
async getLinkedIdentity(userId: number) {
|
||||
return this.userService.getUserOidcIdentity(userId);
|
||||
}
|
||||
|
||||
async unlinkIdentity(userId: number, password: string): Promise<void> {
|
||||
const hash = await this.userService.findPasswordHashById(userId);
|
||||
if (!hash) throw new BadRequestException('User not found');
|
||||
|
||||
const valid = await compare(password, hash);
|
||||
if (!valid) throw new BadRequestException('Incorrect password');
|
||||
|
||||
const identity = await this.userService.getUserOidcIdentity(userId);
|
||||
await this.userService.unlinkOidcIdentity(userId);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId,
|
||||
actorUsername: 'self',
|
||||
action: AuditAction.OidcIdentityUnlinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: userId,
|
||||
description: `User unlinked OIDC identity (issuer: ${identity?.oidcIssuer ?? 'unknown'})`,
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: { code: string; codeVerifier: string; redirectUri: string; nonce: string; state: string },
|
||||
reply: FastifyReply,
|
||||
): Promise<OidcCallbackResponse> {
|
||||
const stateResult = await this.stateService.validateAndConsume(params.state);
|
||||
if (!stateResult.valid) {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your login session expired. Please try signing in again.',
|
||||
errorCode: OidcErrorCode.STATE_EXPIRED,
|
||||
});
|
||||
}
|
||||
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
|
||||
// P2-9: Validate redirect URI against allowed app URL
|
||||
const allowedRedirectUri = `${this.appUrl}/oauth2-callback`;
|
||||
if (normalizeRedirectUri(params.redirectUri) !== normalizeRedirectUri(allowedRedirectUri)) {
|
||||
throw new BadRequestException(`Redirect URI is not allowed: ${params.redirectUri}`);
|
||||
}
|
||||
|
||||
const disc = await this.discovery.getDiscoveryDoc(config.issuerUri);
|
||||
|
||||
const tokens = await this.tokenClient.exchangeCode({
|
||||
code: params.code,
|
||||
codeVerifier: params.codeVerifier,
|
||||
redirectUri: params.redirectUri,
|
||||
tokenEndpoint: disc.tokenEndpoint,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
});
|
||||
let tokens: Awaited<ReturnType<typeof this.tokenClient.exchangeCode>>;
|
||||
try {
|
||||
tokens = await this.tokenClient.exchangeCode({
|
||||
code: params.code,
|
||||
codeVerifier: params.codeVerifier,
|
||||
redirectUri: params.redirectUri,
|
||||
tokenEndpoint: disc.tokenEndpoint,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Could not complete sign-in with your provider. Please try again or contact your administrator.',
|
||||
errorCode: OidcErrorCode.TOKEN_EXCHANGE_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const idTokenClaims = await this.tokenValidator.validateIdToken(tokens.idToken, {
|
||||
issuer: disc.issuer,
|
||||
@@ -89,12 +166,43 @@ export class OidcService {
|
||||
const claims = this.claimExtractor.extract(idTokenClaims as Record<string, unknown>, userInfoClaims, config.claimMapping);
|
||||
if (!claims.subject) {
|
||||
this.logger.warn('[auth.oidc_callback] [fail] errorClass=UnauthorizedException error="missing sub claim" - OIDC callback failed');
|
||||
throw new UnauthorizedException('Invalid ID token: missing subject claim');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Could not complete sign-in with your provider. Please try again or contact your administrator.',
|
||||
errorCode: OidcErrorCode.TOKEN_EXCHANGE_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const mode = (stateResult.meta?.mode as string) ?? 'login';
|
||||
|
||||
if (mode === 'preview') {
|
||||
return {
|
||||
mode: 'preview',
|
||||
claims: {
|
||||
raw: { ...(idTokenClaims as Record<string, unknown>), ...userInfoClaims },
|
||||
mapped: { username: claims.username, name: claims.name, email: claims.email, groups: claims.groups },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'link') {
|
||||
const targetUserId = stateResult.meta?.userId as number;
|
||||
await this.userService.linkOidcIdentity(targetUserId, claims.subject, disc.issuer, claims.avatarUrl);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: targetUserId,
|
||||
actorUsername: 'self',
|
||||
action: AuditAction.OidcIdentityLinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: targetUserId,
|
||||
description: `User linked OIDC identity (issuer: ${disc.issuer}, subject: ${claims.subject})`,
|
||||
});
|
||||
|
||||
return { mode: 'link', linked: true };
|
||||
}
|
||||
|
||||
// Default: login flow
|
||||
const user = await this.findOrProvisionUser(claims, config, disc.issuer);
|
||||
|
||||
// Track OIDC session for backchannel logout
|
||||
const sid = (idTokenClaims as Record<string, unknown>).sid ? String((idTokenClaims as Record<string, unknown>).sid) : undefined;
|
||||
await this.sessionRepo.create({
|
||||
userId: user.id,
|
||||
@@ -102,10 +210,21 @@ export class OidcService {
|
||||
oidcIssuer: disc.issuer,
|
||||
oidcSessionId: sid,
|
||||
idTokenHint: tokens.idToken,
|
||||
idpRefreshToken: tokens.refreshToken ?? null,
|
||||
expiresAt: this.authService.getRefreshTokenExpiryDate(),
|
||||
});
|
||||
|
||||
return this.authService.issueTokensForUser(user.id, reply);
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.OidcLogin,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: user.id,
|
||||
description: `User logged in via OIDC (issuer: ${disc.issuer})`,
|
||||
});
|
||||
|
||||
const authResult = await this.authService.issueTokensForUser(user.id, reply);
|
||||
return { mode: 'login' as const, ...authResult } as OidcCallbackResponse;
|
||||
}
|
||||
|
||||
private async findOrProvisionUser(
|
||||
@@ -113,16 +232,22 @@ export class OidcService {
|
||||
config: Awaited<ReturnType<AppSettingsService['getOidcConfig']>>,
|
||||
issuer: string,
|
||||
) {
|
||||
// 1. Look up by OIDC subject+issuer (primary key for OIDC identity)
|
||||
let user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
|
||||
// 2. Fallback: look up by username and link if allowed
|
||||
if (!user && config.autoProvision.allowLocalLinking) {
|
||||
const byUsername = await this.userService.findByUsername(claims.username);
|
||||
if (byUsername) {
|
||||
let linkConflict: unknown;
|
||||
try {
|
||||
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: byUsername.id,
|
||||
actorUsername: byUsername.username,
|
||||
action: AuditAction.OidcIdentityLinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: byUsername.id,
|
||||
description: `OIDC identity auto-linked to existing local account (issuer: ${issuer})`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
linkConflict = error;
|
||||
@@ -134,7 +259,6 @@ export class OidcService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-provision new user
|
||||
let createdUser = false;
|
||||
if (!user && config.autoProvision.enabled) {
|
||||
try {
|
||||
@@ -154,22 +278,36 @@ export class OidcService {
|
||||
}
|
||||
|
||||
if (createdUser && config.autoProvision.defaultPermissionNames?.length) {
|
||||
await this.userService.setPermissionsDirectly(user.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
await this.userService.setPermissionsDirectly(user!.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
}
|
||||
|
||||
if (createdUser && user) {
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.OidcUserProvisioned,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: user.id,
|
||||
description: `User auto-provisioned via OIDC (issuer: ${issuer}, subject: ${claims.subject})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found and auto-provisioning is disabled');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your account has not been set up. Contact your administrator for access.',
|
||||
errorCode: OidcErrorCode.USER_NOT_PROVISIONED,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
throw new UnauthorizedException('Account is deactivated');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your account has been deactivated. Contact your administrator.',
|
||||
errorCode: OidcErrorCode.USER_INACTIVE,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync OIDC group-to-role mappings
|
||||
if (claims.groups.length > 0) {
|
||||
await this.groupMapping.syncUserGroups(user.id, claims.groups);
|
||||
}
|
||||
await this.groupMapping.syncUserGroups(user.id, claims.groups);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -236,6 +236,26 @@ export class UserRepository {
|
||||
.where(eq(schema.users.id, userId));
|
||||
}
|
||||
|
||||
async unlinkOidcIdentity(userId: number) {
|
||||
await this.db.update(schema.users).set({ oidcSubject: null, oidcIssuer: null, provisioningMethod: 'local' }).where(eq(schema.users.id, userId));
|
||||
}
|
||||
|
||||
async getUserOidcIdentity(userId: number): Promise<{ oidcSubject: string | null; oidcIssuer: string | null } | null> {
|
||||
const row = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
columns: { oidcSubject: true, oidcIssuer: true },
|
||||
});
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findPasswordHashById(userId: number): Promise<string | null> {
|
||||
const row = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
columns: { passwordHash: true },
|
||||
});
|
||||
return row?.passwordHash ?? null;
|
||||
}
|
||||
|
||||
async createOidcUser(data: { username: string; name: string; email?: string; oidcSubject: string; oidcIssuer: string; avatarUrl?: string }) {
|
||||
const passwordHash = await hash(`OIDC_USER_${randomUUID()}`, 12);
|
||||
const [user] = await this.db
|
||||
|
||||
@@ -34,6 +34,18 @@ export class UserService {
|
||||
return this.userRepo.linkOidcIdentity(userId, oidcSubject, oidcIssuer, avatarUrl);
|
||||
}
|
||||
|
||||
unlinkOidcIdentity(userId: number) {
|
||||
return this.userRepo.unlinkOidcIdentity(userId);
|
||||
}
|
||||
|
||||
getUserOidcIdentity(userId: number) {
|
||||
return this.userRepo.getUserOidcIdentity(userId);
|
||||
}
|
||||
|
||||
findPasswordHashById(userId: number) {
|
||||
return this.userRepo.findPasswordHashById(userId);
|
||||
}
|
||||
|
||||
createOidcUser(data: Parameters<UserRepository['createOidcUser']>[0]) {
|
||||
return this.userRepo.createOidcUser(data);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user