mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(auth): explain untrusted OIDC certificate failures
Classify private trust-chain failures with a stable error code and direct administrators to a mounted CA bundle without weakening TLS verification. Fixes #1346
This commit is contained in:
@@ -66,6 +66,9 @@ APP_DATA_PATH=../local/data
|
||||
# Allow OIDC issuer/discovery URLs that resolve to private/local addresses in production.
|
||||
# Default false. Keep false unless you intentionally trust your private network.
|
||||
# OIDC_ALLOW_LOCAL_ISSUERS=false
|
||||
# Trust additional PEM-encoded certificate authorities for outbound TLS, including OIDC.
|
||||
# Mount the CA bundle read-only, set its in-container path here, and restart BookOrbit.
|
||||
# NODE_EXTRA_CA_CERTS=/path/to/private-ca-bundle.pem
|
||||
|
||||
# Disable username/password sign-in after at least one active administrator has linked an enabled
|
||||
# OIDC provider. Set false and restart to recover access if the identity provider is unavailable.
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('OidcProviderService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -193,7 +194,6 @@ describe('OidcProviderService', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
|
||||
await service.testConnection('https://kc.example.com/realms/main');
|
||||
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('passes allowLocal/allowPrivate=false in production by default', async () => {
|
||||
@@ -201,7 +201,6 @@ describe('OidcProviderService', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
|
||||
await prodService.testConnection('https://kc.example.com/realms/main');
|
||||
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false, allowPrivate: false });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('passes allowLocal/allowPrivate=true in production when override is enabled', async () => {
|
||||
@@ -209,7 +208,6 @@ describe('OidcProviderService', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
|
||||
await prodService.testConnection('https://kc.example.com/realms/main');
|
||||
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('re-throws PrivateAddressException as BadRequestException with OIDC_ALLOW_LOCAL_ISSUERS hint', async () => {
|
||||
@@ -227,5 +225,56 @@ describe('OidcProviderService', () => {
|
||||
vi.mocked(ensureSafeUrl).mockRejectedValueOnce(original);
|
||||
await expect(service.testConnection('https://bad-url')).rejects.toBe(original);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'CERT_UNTRUSTED',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
])('returns a structured error for nested TLS trust failure %s', async (code) => {
|
||||
const tlsError = Object.assign(new Error('certificate verification failed'), { code });
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed', { cause: tlsError })));
|
||||
|
||||
const err = await service.testConnection('https://kc.example.com/realms/main').catch((error) => error);
|
||||
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse()).toEqual({
|
||||
message: 'The OIDC provider TLS certificate is not trusted by BookOrbit.',
|
||||
errorCode: OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds a TLS trust failure inside an aggregate fetch error', async () => {
|
||||
const tlsError = Object.assign(new Error('unable to verify leaf signature'), { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockRejectedValue(
|
||||
Object.assign(new TypeError('fetch failed'), {
|
||||
errors: [Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }), tlsError],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const err = await service.testConnection('https://kc.example.com/realms/main').catch((error) => error);
|
||||
|
||||
expect(err.getResponse()).toMatchObject({ errorCode: OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED });
|
||||
});
|
||||
|
||||
it('keeps the existing generic response for unrelated fetch failures', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockRejectedValue(new TypeError('fetch failed', { cause: Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' }) })),
|
||||
);
|
||||
|
||||
const err = await service.testConnection('https://kc.example.com/realms/main').catch((error) => error);
|
||||
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
expect(err.getResponse()).toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'OIDC connection test failed: fetch failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,20 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { OidcErrorCode } from '@bookorbit/types';
|
||||
import type { OidcAutoProvision, OidcClaimMapping } from '@bookorbit/types';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { ensureSafeUrl, PrivateAddressException } from '../../common/utils/ssrf.utils';
|
||||
import { OidcProviderRepository } from './oidc-provider.repository';
|
||||
|
||||
const OIDC_TEST_TIMEOUT_MS = 10_000;
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const TLS_TRUST_ERROR_CODES = new Set([
|
||||
'CERT_UNTRUSTED',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class OidcProviderService {
|
||||
@@ -138,7 +147,9 @@ export class OidcProviderService {
|
||||
const json: unknown = await res.json();
|
||||
if (!isOidcDiscoveryDoc(json)) throw new BadRequestException('Provider returned an invalid discovery document');
|
||||
|
||||
this.logger.log(`[oidc_provider.test_connection] [end] issuerUri=${issuerUri} durationMs=${Date.now() - start} - connection test succeeded`);
|
||||
this.logger.log(
|
||||
`[oidc_provider.test_connection] [end] issuerUri="${sanitizeLogValue(issuerUri)}" durationMs=${Date.now() - start} - connection test succeeded`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
issuer: json.issuer,
|
||||
@@ -157,13 +168,19 @@ export class OidcProviderService {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof BadRequestException) {
|
||||
this.logger.warn(
|
||||
`[oidc_provider.test_connection] [fail] issuerUri=${issuerUri} durationMs=${durationMs} errorClass=${errorClass} error="${message}" - test rejected`,
|
||||
`[oidc_provider.test_connection] [fail] issuerUri="${sanitizeLogValue(issuerUri)}" durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - test rejected`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
this.logger.warn(
|
||||
`[oidc_provider.test_connection] [fail] issuerUri=${issuerUri} durationMs=${durationMs} errorClass=${errorClass} error="${message}" - test failed`,
|
||||
`[oidc_provider.test_connection] [fail] issuerUri="${sanitizeLogValue(issuerUri)}" durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - test failed`,
|
||||
);
|
||||
if (hasTlsTrustError(err)) {
|
||||
throw new BadRequestException({
|
||||
message: 'The OIDC provider TLS certificate is not trusted by BookOrbit.',
|
||||
errorCode: OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED,
|
||||
});
|
||||
}
|
||||
throw new BadRequestException(`OIDC connection test failed: ${message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
@@ -212,3 +229,21 @@ function isOidcDiscoveryDoc(val: unknown): val is { issuer: string; authorizatio
|
||||
typeof (val as Record<string, unknown>)['authorization_endpoint'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function hasTlsTrustError(error: unknown): boolean {
|
||||
const pending = [error];
|
||||
const visited = new Set<object>();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (typeof current !== 'object' || current === null || visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
const errorLike = current as { cause?: unknown; code?: unknown; errors?: unknown };
|
||||
if (typeof errorLike.code === 'string' && TLS_TRUST_ERROR_CODES.has(errorLike.code)) return true;
|
||||
if (errorLike.cause !== undefined) pending.push(errorLike.cause);
|
||||
if (Array.isArray(errorLike.errors)) pending.push(...errorLike.errors);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user