fix(auth): surface private OIDC issuer guidance

Return a stable error code for private issuer rejections and translate it in settings so administrators can apply the required production override.

Closes #1065
This commit is contained in:
neon
2026-08-21 17:04:15 +00:00
parent 1e5fcedb2a
commit 9d116c3e97
6 changed files with 107 additions and 5 deletions
+15 -2
View File
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
import { api } from '@/lib/api'
import { generatePkce } from '@/features/auth/composables/useOidc'
import { toast } from 'vue-sonner'
import { Permission, PERMISSION_LABELS } from '@bookorbit/types'
import { OidcErrorCode, Permission, PERMISSION_LABELS } from '@bookorbit/types'
import { ArrowLeft, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -17,6 +17,10 @@ const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
const { t } = useI18n()
const OIDC_TEST_ERROR_MESSAGE_KEYS: Record<string, string> = {
[OidcErrorCode.PRIVATE_ISSUER_ADDRESS]: 'settings.oidc.test.privateIssuerAddress',
}
interface ProviderSummary {
id: number
slug: string
@@ -281,7 +285,16 @@ async function testConnection() {
try {
const slug = editingSlug.value ?? form.slug ?? '_new'
const res = await api(`/api/v1/app-settings/oidc/providers/${slug}/test?issuerUri=${encodeURIComponent(form.issuerUri)}`, { method: 'POST' })
testResult.value = await res.json()
const body = await res.json()
if (!res.ok) {
const messageKey = typeof body?.errorCode === 'string' ? OIDC_TEST_ERROR_MESSAGE_KEYS[body.errorCode] : undefined
testResult.value = {
success: false,
error: messageKey ? t(messageKey) : t('settings.oidc.test.connectionFailed'),
}
return
}
testResult.value = body
} catch {
testResult.value = {
success: false,
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { OidcErrorCode } from '@bookorbit/types'
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
vi.mock('@/lib/api', () => ({
api: apiMock,
}))
vi.mock('vue-sonner', () => ({
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
}))
import OidcSettings from '../OidcSettings.vue'
function response(ok: boolean, body: unknown) {
return { ok, json: async () => body } as Response
}
async function mountCreateForm() {
const wrapper = mount(OidcSettings, { props: { embedded: true } })
await flushPromises()
const addProvider = wrapper.findAll('button').find((button) => button.text().includes('Add Provider'))
expect(addProvider).toBeDefined()
await addProvider?.trigger('click')
await wrapper.get('input[placeholder="https://accounts.example.com"]').setValue('https://pocket-id.example.com')
return wrapper
}
async function testConnection(wrapper: VueWrapper) {
const testButton = wrapper.findAll('button').find((button) => button.text() === 'Test')
expect(testButton).toBeDefined()
await testButton?.trigger('click')
await flushPromises()
}
describe('OidcSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
apiMock.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, [])
return response(false, {})
})
})
it('localizes the private issuer error code returned by the server', async () => {
apiMock.mockImplementation(async (input: RequestInfo | URL) => {
if (String(input) === '/api/v1/app-settings/oidc/providers') return response(true, [])
return response(false, {
errorCode: OidcErrorCode.PRIVATE_ISSUER_ADDRESS,
message: 'Server copy must not be rendered',
})
})
const wrapper = await mountCreateForm()
await testConnection(wrapper)
expect(wrapper.text()).toContain('The issuer URL resolves to a private or local network address')
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, [])
return response(false, {
errorCode: 'oidc_unknown_error',
message: 'Untranslated server copy',
})
})
const wrapper = await mountCreateForm()
await testConnection(wrapper)
expect(wrapper.text()).toContain('Connection failed')
expect(wrapper.text()).not.toContain('Untranslated server copy')
})
})
+1
View File
@@ -731,6 +731,7 @@
"test": {
"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.",
"hide": "Hide",
"details": "Details",
"supported": "supported",
+1
View File
@@ -4,6 +4,7 @@ export enum LoginErrorCode {
export enum OidcErrorCode {
STATE_EXPIRED = "oidc_state_expired",
PRIVATE_ISSUER_ADDRESS = "oidc_private_issuer_address",
TOKEN_EXCHANGE_FAILED = "oidc_token_exchange_failed",
USER_NOT_PROVISIONED = "oidc_user_not_provisioned",
USER_INACTIVE = "oidc_user_inactive",
@@ -1,5 +1,6 @@
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OidcErrorCode } from '@bookorbit/types';
vi.mock('../../common/utils/ssrf.utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../common/utils/ssrf.utils')>();
@@ -217,6 +218,7 @@ describe('OidcProviderService', () => {
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse()).toMatchObject({
message: expect.stringContaining('OIDC_ALLOW_LOCAL_ISSUERS=true'),
errorCode: OidcErrorCode.PRIVATE_ISSUER_ADDRESS,
});
});
@@ -1,5 +1,6 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OidcErrorCode } from '@bookorbit/types';
import type { OidcAutoProvision, OidcClaimMapping } from '@bookorbit/types';
import { ensureSafeUrl, PrivateAddressException } from '../../common/utils/ssrf.utils';
@@ -114,9 +115,10 @@ export class OidcProviderService {
parsed = await ensureSafeUrl(issuerUri, { allowLocal: allowPrivateOidcIssuers, allowPrivate: allowPrivateOidcIssuers });
} catch (err) {
if (err instanceof PrivateAddressException) {
throw new BadRequestException(
'Issuer URL resolves to a private or local address. To allow private network OIDC providers, set OIDC_ALLOW_LOCAL_ISSUERS=true.',
);
throw new BadRequestException({
message: 'Issuer URL resolves to a private or local address. To allow private network OIDC providers, set OIDC_ALLOW_LOCAL_ISSUERS=true.',
errorCode: OidcErrorCode.PRIVATE_ISSUER_ADDRESS,
});
}
throw err;
}