mirror of
https://github.com/unraid/api.git
synced 2026-01-01 06:01:18 -06:00
fix: oidc cache busting issues fixed (#1656)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - Bug Fixes - SSO/OIDC provider changes now take effect immediately by clearing caches on updates, deletes, and settings changes. - Updating a provider’s issuer no longer requires an API restart. - Tests - Added extensive test coverage for OIDC config caching, including per‑provider and global invalidation and manual/automatic configuration paths. - Chores - Updated internal module wiring to resolve circular dependencies; no user-facing changes. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||||
|
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||||
|
import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
|
||||||
|
|
||||||
|
vi.mock('openid-client');
|
||||||
|
|
||||||
|
describe('OidcClientConfigService - Cache Behavior', () => {
|
||||||
|
let service: OidcClientConfigService;
|
||||||
|
let validationService: OidcValidationService;
|
||||||
|
|
||||||
|
const createMockProvider = (port: number): OidcProvider => ({
|
||||||
|
id: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-secret',
|
||||||
|
issuer: `http://localhost:${port}`,
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
authorizationRules: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockConfiguration = (port: number) => {
|
||||||
|
const mockConfig = {
|
||||||
|
serverMetadata: vi.fn(() => ({
|
||||||
|
issuer: `http://localhost:${port}`,
|
||||||
|
authorization_endpoint: `http://localhost:${port}/auth`,
|
||||||
|
token_endpoint: `http://localhost:${port}/token`,
|
||||||
|
jwks_uri: `http://localhost:${port}/jwks`,
|
||||||
|
userinfo_endpoint: `http://localhost:${port}/userinfo`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return mockConfig as unknown as client.Configuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
const mockConfigService = {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OidcClientConfigService,
|
||||||
|
OidcValidationService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<OidcClientConfigService>(OidcClientConfigService);
|
||||||
|
validationService = module.get<OidcValidationService>(OidcValidationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Configuration Caching', () => {
|
||||||
|
it('should cache configuration on first call', async () => {
|
||||||
|
const provider = createMockProvider(1029);
|
||||||
|
const mockConfig = createMockConfiguration(1029);
|
||||||
|
|
||||||
|
vi.spyOn(validationService, 'performDiscovery').mockResolvedValueOnce(mockConfig);
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const config1 = await service.getOrCreateConfig(provider);
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||||
|
|
||||||
|
// Second call with same provider ID should use cache
|
||||||
|
const config2 = await service.getOrCreateConfig(provider);
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||||
|
expect(config2).toBe(config1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return stale cached configuration when issuer changes without cache clear', async () => {
|
||||||
|
const provider1029 = createMockProvider(1029);
|
||||||
|
const provider1030 = createMockProvider(1030);
|
||||||
|
const mockConfig1029 = createMockConfiguration(1029);
|
||||||
|
const mockConfig1030 = createMockConfiguration(1030);
|
||||||
|
|
||||||
|
vi.spyOn(validationService, 'performDiscovery')
|
||||||
|
.mockResolvedValueOnce(mockConfig1029)
|
||||||
|
.mockResolvedValueOnce(mockConfig1030);
|
||||||
|
|
||||||
|
// Initial configuration on port 1029
|
||||||
|
const config1 = await service.getOrCreateConfig(provider1029);
|
||||||
|
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||||
|
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||||
|
|
||||||
|
// Update provider to port 1030 (simulating UI change)
|
||||||
|
// Without clearing cache, it should still return the old cached config
|
||||||
|
const config2 = await service.getOrCreateConfig(provider1030);
|
||||||
|
|
||||||
|
// THIS IS THE BUG: The service returns cached config for port 1029
|
||||||
|
// even though the provider now has issuer on port 1030
|
||||||
|
expect(config2.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||||
|
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||||
|
|
||||||
|
// performDiscovery should only be called once because cache is used
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fresh configuration after cache is cleared', async () => {
|
||||||
|
const provider1029 = createMockProvider(1029);
|
||||||
|
const provider1030 = createMockProvider(1030);
|
||||||
|
const mockConfig1029 = createMockConfiguration(1029);
|
||||||
|
const mockConfig1030 = createMockConfiguration(1030);
|
||||||
|
|
||||||
|
vi.spyOn(validationService, 'performDiscovery')
|
||||||
|
.mockResolvedValueOnce(mockConfig1029)
|
||||||
|
.mockResolvedValueOnce(mockConfig1030);
|
||||||
|
|
||||||
|
// Initial configuration on port 1029
|
||||||
|
const config1 = await service.getOrCreateConfig(provider1029);
|
||||||
|
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||||
|
|
||||||
|
// Clear cache for the provider
|
||||||
|
service.clearCache(provider1030.id);
|
||||||
|
|
||||||
|
// Now it should fetch fresh config for port 1030
|
||||||
|
const config2 = await service.getOrCreateConfig(provider1030);
|
||||||
|
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
|
||||||
|
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||||
|
|
||||||
|
// performDiscovery should be called twice (once for each port)
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all provider caches when clearCache is called without providerId', async () => {
|
||||||
|
const provider1 = { ...createMockProvider(1029), id: 'provider1' };
|
||||||
|
const provider2 = { ...createMockProvider(1030), id: 'provider2' };
|
||||||
|
const mockConfig1 = createMockConfiguration(1029);
|
||||||
|
const mockConfig2 = createMockConfiguration(1030);
|
||||||
|
|
||||||
|
vi.spyOn(validationService, 'performDiscovery')
|
||||||
|
.mockResolvedValueOnce(mockConfig1)
|
||||||
|
.mockResolvedValueOnce(mockConfig2)
|
||||||
|
.mockResolvedValueOnce(mockConfig1)
|
||||||
|
.mockResolvedValueOnce(mockConfig2);
|
||||||
|
|
||||||
|
// Cache both providers
|
||||||
|
await service.getOrCreateConfig(provider1);
|
||||||
|
await service.getOrCreateConfig(provider2);
|
||||||
|
expect(service.getCacheSize()).toBe(2);
|
||||||
|
|
||||||
|
// Clear all caches
|
||||||
|
service.clearCache();
|
||||||
|
expect(service.getCacheSize()).toBe(0);
|
||||||
|
|
||||||
|
// Both should fetch fresh configs
|
||||||
|
await service.getOrCreateConfig(provider1);
|
||||||
|
await service.getOrCreateConfig(provider2);
|
||||||
|
|
||||||
|
// performDiscovery should be called 4 times total
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Manual Configuration Caching', () => {
|
||||||
|
it('should cache manual configuration and exhibit same stale cache issue', async () => {
|
||||||
|
const provider1029: OidcProvider = {
|
||||||
|
id: 'manual-provider',
|
||||||
|
name: 'Manual Provider',
|
||||||
|
clientId: 'client-id',
|
||||||
|
clientSecret: 'secret',
|
||||||
|
issuer: '',
|
||||||
|
authorizationEndpoint: 'http://localhost:1029/auth',
|
||||||
|
tokenEndpoint: 'http://localhost:1029/token',
|
||||||
|
scopes: ['openid'],
|
||||||
|
authorizationRules: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider1030: OidcProvider = {
|
||||||
|
...provider1029,
|
||||||
|
authorizationEndpoint: 'http://localhost:1030/auth',
|
||||||
|
tokenEndpoint: 'http://localhost:1030/token',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the client.Configuration constructor for manual configs
|
||||||
|
const mockManualConfig1029 = createMockConfiguration(1029);
|
||||||
|
const mockManualConfig1030 = createMockConfiguration(1030);
|
||||||
|
|
||||||
|
let configCallCount = 0;
|
||||||
|
vi.mocked(client.Configuration).mockImplementation(() => {
|
||||||
|
configCallCount++;
|
||||||
|
return configCallCount === 1 ? mockManualConfig1029 : mockManualConfig1030;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(client.ClientSecretPost).mockReturnValue({} as any);
|
||||||
|
vi.mocked(client.allowInsecureRequests).mockImplementation(() => {});
|
||||||
|
|
||||||
|
// First call with port 1029
|
||||||
|
const config1 = await service.getOrCreateConfig(provider1029);
|
||||||
|
expect(config1.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||||
|
|
||||||
|
// Update to port 1030 without clearing cache
|
||||||
|
const config2 = await service.getOrCreateConfig(provider1030);
|
||||||
|
|
||||||
|
// BUG: Still returns cached config with port 1029
|
||||||
|
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1029/auth');
|
||||||
|
|
||||||
|
// Clear cache and try again
|
||||||
|
service.clearCache(provider1030.id);
|
||||||
|
const config3 = await service.getOrCreateConfig(provider1030);
|
||||||
|
|
||||||
|
// Now it should return the updated config
|
||||||
|
expect(config3.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||||
import { OidcRedirectUriService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-redirect-uri.service.js';
|
import { OidcRedirectUriService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-redirect-uri.service.js';
|
||||||
import { OidcBaseModule } from '@app/unraid-api/graph/resolvers/sso/core/oidc-base.module.js';
|
import { OidcBaseModule } from '@app/unraid-api/graph/resolvers/sso/core/oidc-base.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OidcBaseModule],
|
imports: [forwardRef(() => OidcBaseModule)],
|
||||||
providers: [OidcClientConfigService, OidcRedirectUriService],
|
providers: [OidcClientConfigService, OidcRedirectUriService],
|
||||||
exports: [OidcClientConfigService, OidcRedirectUriService],
|
exports: [OidcClientConfigService, OidcRedirectUriService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
||||||
|
|
||||||
|
import { OidcClientModule } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client.module.js';
|
||||||
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
||||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UserSettingsModule],
|
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
|
||||||
providers: [OidcConfigPersistence, OidcValidationService],
|
providers: [OidcConfigPersistence, OidcValidationService],
|
||||||
exports: [OidcConfigPersistence, OidcValidationService],
|
exports: [OidcConfigPersistence, OidcValidationService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
|
||||||
|
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||||
|
import * as client from 'openid-client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||||
|
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js';
|
||||||
|
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||||
|
import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-provider.model.js';
|
||||||
|
|
||||||
|
vi.mock('openid-client');
|
||||||
|
vi.mock('fs/promises', () => ({
|
||||||
|
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||||
|
stat: vi.fn().mockRejectedValue(new Error('File not found')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OIDC Config Cache Fix - Integration Test', () => {
|
||||||
|
let configPersistence: OidcConfigPersistence;
|
||||||
|
let clientConfigService: OidcClientConfigService;
|
||||||
|
let mockConfigService: any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.PATHS_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockProvider = (port: number): OidcProvider => ({
|
||||||
|
id: 'test-provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
clientId: 'test-client-id',
|
||||||
|
clientSecret: 'test-secret',
|
||||||
|
issuer: `http://localhost:${port}`,
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
authorizationRules: [
|
||||||
|
{
|
||||||
|
claim: 'email',
|
||||||
|
operator: 'endsWith' as any,
|
||||||
|
value: ['@example.com'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockConfiguration = (port: number) => {
|
||||||
|
const mockConfig = {
|
||||||
|
serverMetadata: vi.fn(() => ({
|
||||||
|
issuer: `http://localhost:${port}`,
|
||||||
|
authorization_endpoint: `http://localhost:${port}/auth`,
|
||||||
|
token_endpoint: `http://localhost:${port}/token`,
|
||||||
|
jwks_uri: `http://localhost:${port}/jwks`,
|
||||||
|
userinfo_endpoint: `http://localhost:${port}/userinfo`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return mockConfig as unknown as client.Configuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Set environment variable for config path
|
||||||
|
process.env.PATHS_CONFIG = '/tmp/test-config';
|
||||||
|
|
||||||
|
mockConfigService = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'oidc') {
|
||||||
|
return {
|
||||||
|
providers: [createMockProvider(1029)],
|
||||||
|
defaultAllowedOrigins: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (key === 'paths.config') {
|
||||||
|
return '/tmp/test-config';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
set: vi.fn(),
|
||||||
|
getOrThrow: vi.fn((key: string) => {
|
||||||
|
if (key === 'paths.config' || key === 'paths') {
|
||||||
|
return '/tmp/test-config';
|
||||||
|
}
|
||||||
|
return '/tmp/test-config';
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserSettingsService = {
|
||||||
|
register: vi.fn(),
|
||||||
|
getAllSettings: vi.fn(),
|
||||||
|
getAllValues: vi.fn(),
|
||||||
|
updateNamespacedValues: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OidcConfigPersistence,
|
||||||
|
OidcClientConfigService,
|
||||||
|
OidcValidationService,
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: mockConfigService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: UserSettingsService,
|
||||||
|
useValue: mockUserSettingsService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
configPersistence = module.get<OidcConfigPersistence>(OidcConfigPersistence);
|
||||||
|
clientConfigService = module.get<OidcClientConfigService>(OidcClientConfigService);
|
||||||
|
|
||||||
|
// Mock the persist method since we don't want to write to disk in tests
|
||||||
|
vi.spyOn(configPersistence as any, 'persist').mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache clearing on provider update', () => {
|
||||||
|
it('should clear cache when provider is updated via upsertProvider', async () => {
|
||||||
|
const provider1029 = createMockProvider(1029);
|
||||||
|
const provider1030 = createMockProvider(1030);
|
||||||
|
const mockConfig1029 = createMockConfiguration(1029);
|
||||||
|
const mockConfig1030 = createMockConfiguration(1030);
|
||||||
|
|
||||||
|
// Mock validation service to return configs
|
||||||
|
const validationService = (configPersistence as any).validationService;
|
||||||
|
vi.spyOn(validationService, 'performDiscovery')
|
||||||
|
.mockResolvedValueOnce(mockConfig1029)
|
||||||
|
.mockResolvedValueOnce(mockConfig1030);
|
||||||
|
|
||||||
|
// First, get config for port 1029 - this caches it
|
||||||
|
const config1 = await clientConfigService.getOrCreateConfig(provider1029);
|
||||||
|
expect(config1.serverMetadata().issuer).toBe('http://localhost:1029');
|
||||||
|
|
||||||
|
// Spy on clearCache method
|
||||||
|
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||||
|
|
||||||
|
// Update the provider to port 1030 via upsertProvider
|
||||||
|
await configPersistence.upsertProvider(provider1030);
|
||||||
|
|
||||||
|
// Verify cache was cleared for this specific provider
|
||||||
|
expect(clearCacheSpy).toHaveBeenCalledWith(provider1030.id);
|
||||||
|
|
||||||
|
// Now get config again - should fetch fresh config for port 1030
|
||||||
|
const config2 = await clientConfigService.getOrCreateConfig(provider1030);
|
||||||
|
expect(config2.serverMetadata().issuer).toBe('http://localhost:1030');
|
||||||
|
expect(config2.serverMetadata().authorization_endpoint).toBe('http://localhost:1030/auth');
|
||||||
|
|
||||||
|
// Verify discovery was called twice (not using cache)
|
||||||
|
expect(validationService.performDiscovery).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear cache when provider is deleted', async () => {
|
||||||
|
const provider = createMockProvider(1029);
|
||||||
|
const mockConfig = createMockConfiguration(1029);
|
||||||
|
|
||||||
|
// Setup initial provider in config
|
||||||
|
mockConfigService.get.mockReturnValue({
|
||||||
|
providers: [provider, { ...provider, id: 'other-provider' }],
|
||||||
|
defaultAllowedOrigins: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock validation service
|
||||||
|
const validationService = (configPersistence as any).validationService;
|
||||||
|
vi.spyOn(validationService, 'performDiscovery').mockResolvedValue(mockConfig);
|
||||||
|
|
||||||
|
// First, cache the provider config
|
||||||
|
await clientConfigService.getOrCreateConfig(provider);
|
||||||
|
|
||||||
|
// Spy on clearCache
|
||||||
|
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||||
|
|
||||||
|
// Delete the provider
|
||||||
|
const deleted = await configPersistence.deleteProvider(provider.id);
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
|
||||||
|
// Verify cache was cleared for the deleted provider
|
||||||
|
expect(clearCacheSpy).toHaveBeenCalledWith(provider.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all provider caches when updated via settings updateValues', async () => {
|
||||||
|
// This simulates what happens when settings are saved through the UI
|
||||||
|
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
...createMockProvider(1030),
|
||||||
|
authorizationMode: 'simple',
|
||||||
|
simpleAuthorization: {
|
||||||
|
allowedDomains: ['example.com'],
|
||||||
|
allowedEmails: [],
|
||||||
|
allowedUserIds: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultAllowedOrigins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spy on clearCache
|
||||||
|
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||||
|
|
||||||
|
// Mock validation
|
||||||
|
const validationService = (configPersistence as any).validationService;
|
||||||
|
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call the updateValues function (simulating saving settings from UI)
|
||||||
|
await settingsCallback.updateValues(newConfig);
|
||||||
|
|
||||||
|
// Verify cache was cleared (called without arguments to clear all)
|
||||||
|
expect(clearCacheSpy).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT require API restart after updating provider issuer', async () => {
|
||||||
|
// This test confirms that the fix eliminates the need for API restart
|
||||||
|
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
providers: [createMockProvider(1030)],
|
||||||
|
defaultAllowedOrigins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock validation
|
||||||
|
const validationService = (configPersistence as any).validationService;
|
||||||
|
vi.spyOn(validationService, 'validateProvider').mockResolvedValue({
|
||||||
|
isValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
const result = await settingsCallback.updateValues(newConfig);
|
||||||
|
|
||||||
|
// Verify that restartRequired is false
|
||||||
|
expect(result.restartRequired).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Provider validation on save', () => {
|
||||||
|
it('should validate providers and include warnings but still save', async () => {
|
||||||
|
const settingsCallback = (configPersistence as any).userSettings.register.mock.calls[0][1];
|
||||||
|
|
||||||
|
const newConfig = {
|
||||||
|
providers: [
|
||||||
|
createMockProvider(1030),
|
||||||
|
{ ...createMockProvider(1031), id: 'invalid-provider', name: 'Invalid Provider' },
|
||||||
|
],
|
||||||
|
defaultAllowedOrigins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock validation - first provider valid, second invalid
|
||||||
|
const validationService = (configPersistence as any).validationService;
|
||||||
|
vi.spyOn(validationService, 'validateProvider')
|
||||||
|
.mockResolvedValueOnce({ isValid: true })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isValid: false,
|
||||||
|
error: 'Discovery failed: Unable to reach issuer',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
const result = await settingsCallback.updateValues(newConfig);
|
||||||
|
|
||||||
|
// Should save successfully but include warnings
|
||||||
|
expect(result.restartRequired).toBe(false);
|
||||||
|
expect(result.warnings).toBeDefined();
|
||||||
|
expect(result.warnings).toContain(
|
||||||
|
'❌ Invalid Provider: Discovery failed: Unable to reach issuer'
|
||||||
|
);
|
||||||
|
expect(result.values.providers).toHaveLength(2);
|
||||||
|
|
||||||
|
// Cache should still be cleared even with validation warnings
|
||||||
|
const clearCacheSpy = vi.spyOn(clientConfigService, 'clearCache');
|
||||||
|
await settingsCallback.updateValues(newConfig);
|
||||||
|
expect(clearCacheSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { RuleEffect } from '@jsonforms/core';
|
import { RuleEffect } from '@jsonforms/core';
|
||||||
@@ -6,6 +6,7 @@ import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
|
|||||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||||
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||||
|
|
||||||
|
import { OidcClientConfigService } from '@app/unraid-api/graph/resolvers/sso/client/oidc-client-config.service.js';
|
||||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||||
import {
|
import {
|
||||||
AuthorizationOperator,
|
AuthorizationOperator,
|
||||||
@@ -30,7 +31,10 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
|||||||
constructor(
|
constructor(
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
private readonly userSettings: UserSettingsService,
|
private readonly userSettings: UserSettingsService,
|
||||||
private readonly validationService: OidcValidationService
|
private readonly validationService: OidcValidationService,
|
||||||
|
@Optional()
|
||||||
|
@Inject(forwardRef(() => OidcClientConfigService))
|
||||||
|
private readonly clientConfigService?: OidcClientConfigService
|
||||||
) {
|
) {
|
||||||
super(configService);
|
super(configService);
|
||||||
this.registerSettings();
|
this.registerSettings();
|
||||||
@@ -252,6 +256,15 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
|||||||
this.configService.set(this.configKey(), newConfig);
|
this.configService.set(this.configKey(), newConfig);
|
||||||
await this.persist(newConfig);
|
await this.persist(newConfig);
|
||||||
|
|
||||||
|
// Clear the OIDC client configuration cache when a provider is updated
|
||||||
|
// This ensures the new issuer/endpoints are used immediately
|
||||||
|
if (this.clientConfigService) {
|
||||||
|
this.clientConfigService.clearCache(cleanedProvider.id);
|
||||||
|
this.logger.debug(
|
||||||
|
`Cleared OIDC client configuration cache for provider ${cleanedProvider.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return cleanedProvider;
|
return cleanedProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +341,12 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
|||||||
this.configService.set(this.configKey(), newConfig);
|
this.configService.set(this.configKey(), newConfig);
|
||||||
await this.persist(newConfig);
|
await this.persist(newConfig);
|
||||||
|
|
||||||
|
// Clear the cache for the deleted provider
|
||||||
|
if (this.clientConfigService) {
|
||||||
|
this.clientConfigService.clearCache(id);
|
||||||
|
this.logger.debug(`Cleared OIDC client configuration cache for deleted provider ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +459,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
|||||||
this.configService.set(this.configKey(), processedConfig);
|
this.configService.set(this.configKey(), processedConfig);
|
||||||
await this.persist(processedConfig);
|
await this.persist(processedConfig);
|
||||||
|
|
||||||
|
// Clear the OIDC client configuration cache to ensure fresh discovery
|
||||||
|
// This fixes the issue where changing issuer URLs requires API restart
|
||||||
|
if (this.clientConfigService) {
|
||||||
|
this.clientConfigService.clearCache();
|
||||||
|
this.logger.debug('Cleared OIDC client configuration cache after provider update');
|
||||||
|
}
|
||||||
|
|
||||||
// Include validation results in response
|
// Include validation results in response
|
||||||
const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = {
|
const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = {
|
||||||
restartRequired: false,
|
restartRequired: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user