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:
neon
2026-04-13 14:44:30 -06:00
parent 75d7aec48d
commit 35594e849e
48 changed files with 2106 additions and 351 deletions
+7
View File
@@ -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
+2 -2
View File
@@ -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 = {
+8
View File
@@ -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": "",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1776080409147,
"when": 1776150932210,
"tag": "0000_baseline",
"breakpoints": true
}
+20
View File
@@ -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);
});
});
+29 -1
View File
@@ -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);
}
}
+2
View File
@@ -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
+214 -66
View File
@@ -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' }));
});
});
});
+169 -31
View File
@@ -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
+12
View File
@@ -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);
}