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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ const { t } = useI18n()
|
||||
|
||||
const OIDC_TEST_ERROR_MESSAGE_KEYS: Record<string, string> = {
|
||||
[OidcErrorCode.PRIVATE_ISSUER_ADDRESS]: 'settings.oidc.test.privateIssuerAddress',
|
||||
[OidcErrorCode.TLS_CERTIFICATE_UNTRUSTED]: 'settings.oidc.test.tlsCertificateUntrusted',
|
||||
}
|
||||
|
||||
interface ProviderSummary {
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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