mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user