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.
|
# 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.
|
||||||
|
|||||||
@@ -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, [])
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user