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:
neon
2026-09-10 11:47:56 +00:00
parent e514be151a
commit 33c7260f18
9 changed files with 118 additions and 6 deletions
+3
View File
@@ -68,6 +68,9 @@ NODE_MAX_OLD_SPACE_SIZE=2048
# Allow OIDC issuer/discovery URLs that resolve to private/local addresses. # Allow OIDC issuer/discovery URLs that resolve to private/local addresses.
# Default false. Enable only in trusted self-hosted networks. # Default false. Enable only in trusted self-hosted networks.
# OIDC_ALLOW_LOCAL_ISSUERS=false # 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 # 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. # OIDC provider. Set false and restart to recover access if the identity provider is unavailable.
+2
View File
@@ -97,6 +97,8 @@ Open `http://your-server-ip:3000` and complete setup using your `SETUP_BOOTSTRAP
After configuring OIDC and linking at least one active administrator, you can set `DISABLE_LOCAL_AUTH=true` and restart BookOrbit to remove and reject password sign-in. BookOrbit refuses to start if that would leave no usable OIDC administrator. Set it back to `false` and restart to recover access during an identity-provider outage. After configuring OIDC and linking at least one active administrator, you can set `DISABLE_LOCAL_AUTH=true` and restart BookOrbit to remove and reject password sign-in. BookOrbit refuses to start if that would leave no usable OIDC administrator. Set it back to `false` and restart to recover access during an identity-provider outage.
An OIDC provider signed by a private certificate authority requires its PEM CA bundle to be mounted read-only in the BookOrbit container. Set `NODE_EXTRA_CA_CERTS` to that in-container path and restart BookOrbit. This adds the private authority to Node's normal certificate verification; it does not disable TLS security. Private-network issuers also require `OIDC_ALLOW_LOCAL_ISSUERS=true`.
For the full installation guide including reverse proxy setup, file permissions on NAS, external databases, and environment variable reference, see **[bookorbit.app/installation](https://bookorbit.app/installation)**. For the full installation guide including reverse proxy setup, file permissions on NAS, external databases, and environment variable reference, see **[bookorbit.app/installation](https://bookorbit.app/installation)**.
## KOReader Plugin ## KOReader Plugin
@@ -19,6 +19,7 @@ const { t } = useI18n()
const OIDC_TEST_ERROR_MESSAGE_KEYS: Record<string, string> = { const OIDC_TEST_ERROR_MESSAGE_KEYS: Record<string, string> = {
[OidcErrorCode.PRIVATE_ISSUER_ADDRESS]: 'settings.oidc.test.privateIssuerAddress', [OidcErrorCode.PRIVATE_ISSUER_ADDRESS]: 'settings.oidc.test.privateIssuerAddress',
[OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED]: 'settings.oidc.test.tlsCertificateUntrusted',
} }
interface ProviderSummary { interface ProviderSummary {
@@ -65,6 +65,23 @@ describe('OidcSettings', () => {
expect(wrapper.text()).not.toContain('Server copy must not be rendered') expect(wrapper.text()).not.toContain('Server copy must not be rendered')
}) })
it('localizes an untrusted TLS certificate error without rendering server copy', async () => {
apiMock.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, [])
return response(false, {
errorCode: OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED,
message: 'Server copy must not be rendered',
})
})
const wrapper = await mountCreateForm()
await testConnection(wrapper)
expect(wrapper.text()).toContain('private certificate authority')
expect(wrapper.text()).toContain('NODE_EXTRA_CA_CERTS')
expect(wrapper.text()).not.toContain('Server copy must not be rendered')
})
it('uses localized generic copy for an unknown server error code', async () => { it('uses localized generic copy for an unknown server error code', async () => {
apiMock.mockImplementation(async (input: RequestInfo | URL) => { apiMock.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, []) if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, [])
+1
View File
@@ -734,6 +734,7 @@
"connected": "Connected - {issuer}", "connected": "Connected - {issuer}",
"connectionFailed": "Connection failed", "connectionFailed": "Connection failed",
"privateIssuerAddress": "The issuer URL resolves to a private or local network address, so the server refused to connect. Set OIDC_ALLOW_LOCAL_ISSUERS=true on the BookOrbit server to allow it.", "privateIssuerAddress": "The issuer URL resolves to a private or local network address, so the server refused to connect. Set OIDC_ALLOW_LOCAL_ISSUERS=true on the BookOrbit server to allow it.",
"tlsCertificateUntrusted": "BookOrbit could not verify the OIDC provider's TLS certificate. If the provider uses a private certificate authority, mount its PEM CA bundle, set NODE_EXTRA_CA_CERTS to the bundle's in-container path, and restart BookOrbit.",
"hide": "Hide", "hide": "Hide",
"details": "Details", "details": "Details",
"supported": "supported", "supported": "supported",
+1
View File
@@ -16,6 +16,7 @@ export type AuthenticationMethod = (typeof AuthenticationMethod)[keyof typeof Au
export enum OidcErrorCode { export enum OidcErrorCode {
STATE_EXPIRED = "oidc_state_expired", STATE_EXPIRED = "oidc_state_expired",
PRIVATE_ISSUER_ADDRESS = "oidc_private_issuer_address", PRIVATE_ISSUER_ADDRESS = "oidc_private_issuer_address",
TLS_CERTIFICATE_UNTRUSTED = "oidc_tls_certificate_untrusted",
TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed", TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed",
USER_NOT_PROVISIONED = "oidc_user_not_provisioned", USER_NOT_PROVISIONED = "oidc_user_not_provisioned",
USER_INACTIVE = "oidc_user_inactive", USER_INACTIVE = "oidc_user_inactive",
+3
View File
@@ -66,6 +66,9 @@ APP_DATA_PATH=../local/data
# Allow OIDC issuer/discovery URLs that resolve to private/local addresses in production. # Allow OIDC issuer/discovery URLs that resolve to private/local addresses in production.
# Default false. Keep false unless you intentionally trust your private network. # Default false. Keep false unless you intentionally trust your private network.
# OIDC_ALLOW_LOCAL_ISSUERS=false # 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 # 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. # OIDC provider. Set false and restart to recover access if the identity provider is unavailable.
@@ -55,6 +55,7 @@ describe('OidcProviderService', () => {
}); });
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -193,7 +194,6 @@ describe('OidcProviderService', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
await service.testConnection('https://kc.example.com/realms/main'); await service.testConnection('https://kc.example.com/realms/main');
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true }); 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 () => { 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) })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
await prodService.testConnection('https://kc.example.com/realms/main'); await prodService.testConnection('https://kc.example.com/realms/main');
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false, allowPrivate: false }); 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 () => { 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) })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) }));
await prodService.testConnection('https://kc.example.com/realms/main'); await prodService.testConnection('https://kc.example.com/realms/main');
expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true }); 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 () => { it('re-throws PrivateAddressException as BadRequestException with OIDC_ALLOW_LOCAL_ISSUERS hint', async () => {
@@ -227,5 +225,56 @@ describe('OidcProviderService', () => {
vi.mocked(ensureSafeUrl).mockRejectedValueOnce(original); vi.mocked(ensureSafeUrl).mockRejectedValueOnce(original);
await expect(service.testConnection('https://bad-url')).rejects.toBe(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 { OidcErrorCode } from '@bookorbit/types';
import type { OidcAutoProvision, OidcClaimMapping } 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 { ensureSafeUrl, PrivateAddressException } from '../../common/utils/ssrf.utils';
import { OidcProviderRepository } from './oidc-provider.repository'; import { OidcProviderRepository } from './oidc-provider.repository';
const OIDC_TEST_TIMEOUT_MS = 10_000; const OIDC_TEST_TIMEOUT_MS = 10_000;
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; 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() @Injectable()
export class OidcProviderService { export class OidcProviderService {
@@ -138,7 +147,9 @@ export class OidcProviderService {
const json: unknown = await res.json(); const json: unknown = await res.json();
if (!isOidcDiscoveryDoc(json)) throw new BadRequestException('Provider returned an invalid discovery document'); 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 { return {
success: true, success: true,
issuer: json.issuer, issuer: json.issuer,
@@ -157,13 +168,19 @@ export class OidcProviderService {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {
this.logger.warn( 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; throw err;
} }
this.logger.warn( 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}`); throw new BadRequestException(`OIDC connection test failed: ${message}`);
} finally { } finally {
clearTimeout(timeout); clearTimeout(timeout);
@@ -212,3 +229,21 @@ function isOidcDiscoveryDoc(val: unknown): val is { issuer: string; authorizatio
typeof (val as Record<string, unknown>)['authorization_endpoint'] === 'string' 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;
}