diff --git a/.env.example b/.env.example index 359998ffb..bea991c08 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,9 @@ NODE_MAX_OLD_SPACE_SIZE=2048 # Allow OIDC issuer/discovery URLs that resolve to private/local addresses. # Default false. Enable only in trusted self-hosted networks. # 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. diff --git a/README.md b/README.md index 7e8277ac8..f0986af5b 100644 --- a/README.md +++ b/README.md @@ -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. +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)**. ## KOReader Plugin diff --git a/client/src/features/settings/OidcSettings.vue b/client/src/features/settings/OidcSettings.vue index 635d37d1c..41ccecff0 100644 --- a/client/src/features/settings/OidcSettings.vue +++ b/client/src/features/settings/OidcSettings.vue @@ -19,6 +19,7 @@ const { t } = useI18n() const OIDC_TEST_ERROR_MESSAGE_KEYS: Record = { [OidcErrorCode.PRIVATE_ISSUER_ADDRESS]: 'settings.oidc.test.privateIssuerAddress', + [OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED]: 'settings.oidc.test.tlsCertificateUntrusted', } interface ProviderSummary { diff --git a/client/src/features/settings/__tests__/OidcSettings.spec.ts b/client/src/features/settings/__tests__/OidcSettings.spec.ts index 091fc9b8e..6431260e2 100644 --- a/client/src/features/settings/__tests__/OidcSettings.spec.ts +++ b/client/src/features/settings/__tests__/OidcSettings.spec.ts @@ -65,6 +65,23 @@ describe('OidcSettings', () => { 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 () => { apiMock.mockImplementation(async (input: RequestInfo | URL) => { if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, []) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d2a2fac98..aa904d500 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -734,6 +734,7 @@ "connected": "Connected - {issuer}", "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.", + "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", "details": "Details", "supported": "supported", diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 0102d816d..1b0664809 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -16,6 +16,7 @@ export type AuthenticationMethod = (typeof AuthenticationMethod)[keyof typeof Au export enum OidcErrorCode { STATE_EXPIRED = "oidc_state_expired", PRIVATE_ISSUER_ADDRESS = "oidc_private_issuer_address", + TLS_CERTIFICATE_UNTRUSTED = "oidc_tls_certificate_untrusted", TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed", USER_NOT_PROVISIONED = "oidc_user_not_provisioned", USER_INACTIVE = "oidc_user_inactive", diff --git a/server/.env.example b/server/.env.example index e53e98817..4be21e9e1 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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. diff --git a/server/src/modules/app-settings/oidc-provider.service.test.ts b/server/src/modules/app-settings/oidc-provider.service.test.ts index d7b72cecd..6cbfe841b 100644 --- a/server/src/modules/app-settings/oidc-provider.service.test.ts +++ b/server/src/modules/app-settings/oidc-provider.service.test.ts @@ -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', + }); + }); }); }); diff --git a/server/src/modules/app-settings/oidc-provider.service.ts b/server/src/modules/app-settings/oidc-provider.service.ts index d18b1590f..8b7f7ed7b 100644 --- a/server/src/modules/app-settings/oidc-provider.service.ts +++ b/server/src/modules/app-settings/oidc-provider.service.ts @@ -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)['authorization_endpoint'] === 'string' ); } + +function hasTlsTrustError(error: unknown): boolean { + const pending = [error]; + const visited = new Set(); + + 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; +}