mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add support for multiple OIDC providers
- implement OIDC provider and identity repositories - allow linking/unlinking multiple OIDC identities to a single user - update login UI to display and handle multiple providers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { OidcPublicConfig } from '@projectx/types'
|
||||
import type { OidcProviderPublic } from '@projectx/types'
|
||||
import { Moon, Sun, Wallpaper } from 'lucide-vue-next'
|
||||
import { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
@@ -49,18 +49,18 @@ function closeAll() {
|
||||
}
|
||||
|
||||
const { login } = useAuth()
|
||||
const { getPublicConfig, initiateLogin } = useOidc()
|
||||
const { getPublicProviders, initiateLogin } = useOidc()
|
||||
const { setupStatusError } = useSetupStatus()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const oidcConfig = ref<OidcPublicConfig | null>(null)
|
||||
const oidcLoading = ref(false)
|
||||
const oidcProviders = ref<OidcProviderPublic[]>([])
|
||||
const oidcLoadingSlug = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
oidcConfig.value = await getPublicConfig()
|
||||
oidcProviders.value = await getPublicProviders()
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -75,14 +75,14 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOidcLogin() {
|
||||
async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
error.value = null
|
||||
oidcLoading.value = true
|
||||
oidcLoadingSlug.value = provider.slug
|
||||
try {
|
||||
await initiateLogin()
|
||||
await initiateLogin(provider)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'OIDC login failed'
|
||||
oidcLoading.value = false
|
||||
oidcLoadingSlug.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -268,25 +268,29 @@ async function handleOidcLogin() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<template v-if="oidcConfig?.enabled">
|
||||
<template v-if="oidcProviders.length > 0">
|
||||
<div class="flex items-center gap-3 my-6">
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
<span class="text-xs text-muted-foreground">or</span>
|
||||
<span class="text-sm text-muted-foreground">or</span>
|
||||
<div class="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="oidcLoading"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background/60 px-4 py-2 text-sm font-medium text-foreground hover:bg-muted/60 disabled:opacity-50 transition-colors backdrop-blur-sm"
|
||||
@click="handleOidcLogin"
|
||||
>
|
||||
<img v-if="oidcConfig.iconUrl" :src="oidcConfig.iconUrl" alt="" class="h-4 w-4 shrink-0 object-contain" />
|
||||
{{ oidcLoading ? 'Redirecting...' : `Sign in with ${oidcConfig.providerName || 'SSO'}` }}
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
v-for="provider in oidcProviders"
|
||||
:key="provider.slug"
|
||||
type="button"
|
||||
:disabled="oidcLoadingSlug !== null"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background/60 px-4 py-2 text-[14px] font-medium text-foreground hover:bg-muted/60 disabled:opacity-50 transition-colors backdrop-blur-sm"
|
||||
@click="handleOidcLogin(provider)"
|
||||
>
|
||||
<img v-if="provider.iconUrl" :src="provider.iconUrl" alt="" class="h-4 w-4 shrink-0 object-contain" />
|
||||
{{ oidcLoadingSlug === provider.slug ? 'Redirecting...' : `Sign in with ${provider.displayName}` }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||
<RouterLink to="/forgot-password" class="text-primary hover:underline">Forgot password?</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -62,9 +62,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
if (data.mode === 'preview') {
|
||||
sessionStorage.removeItem('oidc_preview_pending')
|
||||
sessionStorage.setItem('oidc_preview_claims', JSON.stringify(data.claims))
|
||||
router.replace('/settings/oidc')
|
||||
router.replace('/settings/admin/oidc')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OidcCallbackResponse, OidcPublicConfig } from '@projectx/types'
|
||||
import type { OidcCallbackResponse, OidcProviderPublic } from '@projectx/types'
|
||||
|
||||
async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
export async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
const codeVerifier = btoa(String.fromCharCode(...array))
|
||||
@@ -38,34 +38,31 @@ export class OidcLoginError extends Error {
|
||||
}
|
||||
|
||||
export function useOidc() {
|
||||
async function getPublicConfig(): Promise<OidcPublicConfig | null> {
|
||||
async function getPublicProviders(): Promise<OidcProviderPublic[]> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/app-settings/oidc/public')
|
||||
if (!res.ok) return null
|
||||
const res = await fetch('/api/v1/app-settings/oidc/providers/public')
|
||||
if (!res.ok) return []
|
||||
return res.json()
|
||||
} catch {
|
||||
return null
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function initiateLogin(): Promise<void> {
|
||||
const config = await getPublicConfig()
|
||||
if (!config?.enabled) {
|
||||
throw new OidcLoginError(undefined, 'OIDC is not configured')
|
||||
async function initiateLogin(provider: OidcProviderPublic): Promise<void> {
|
||||
if (!provider.enabled) {
|
||||
throw new OidcLoginError(undefined, 'OIDC provider is not enabled')
|
||||
}
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generatePkce()
|
||||
const nonce = generateNonce()
|
||||
|
||||
// Save any redirect target before leaving the page (P1-1: capture redirect for post-login navigation)
|
||||
sessionStorage.removeItem('oidc_redirect')
|
||||
const redirectTarget = new URLSearchParams(window.location.search).get('redirect')
|
||||
if (redirectTarget && redirectTarget.startsWith('/') && !redirectTarget.startsWith('//')) {
|
||||
sessionStorage.setItem('oidc_redirect', redirectTarget)
|
||||
}
|
||||
|
||||
// Get server-side state + authorizationEndpoint (server resolves discovery - avoids CORS issues)
|
||||
const stateRes = await fetch('/api/v1/auth/oidc/state', { method: 'POST', credentials: 'include' })
|
||||
const stateRes = await fetch(`/api/v1/auth/oidc/${provider.slug}/state`, { method: 'POST', credentials: 'include' })
|
||||
if (!stateRes.ok) throw new OidcLoginError(undefined, 'Failed to generate state')
|
||||
const { state, authorizationEndpoint } = (await stateRes.json()) as { state: string; authorizationEndpoint: string }
|
||||
|
||||
@@ -73,16 +70,15 @@ export function useOidc() {
|
||||
throw new OidcLoginError(undefined, 'OIDC provider is not reachable - check the Issuer URI in settings')
|
||||
}
|
||||
|
||||
// Store PKCE data for the callback
|
||||
sessionStorage.setItem(`oidc_pkce_${state}`, JSON.stringify({ codeVerifier, nonce, state }))
|
||||
|
||||
const redirectUri = `${window.location.origin}/oauth2-callback`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
client_id: provider.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes,
|
||||
scope: provider.scopes,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
@@ -116,5 +112,5 @@ export function useOidc() {
|
||||
return res.json() as Promise<OidcCallbackResponse>
|
||||
}
|
||||
|
||||
return { getPublicConfig, initiateLogin, exchangeCode }
|
||||
return { getPublicProviders, initiateLogin, exchangeCode }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { OidcProviderPublic } from '@projectx/types'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Link, LinkIcon, Save, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { generatePkce } from '@/features/auth/composables/useOidc'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/composables/useProfileAvatar'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
@@ -130,57 +132,80 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
interface OidcIdentity {
|
||||
oidcSubject: string | null
|
||||
oidcIssuer: string | null
|
||||
interface LinkedIdentity {
|
||||
id: number
|
||||
providerId: number
|
||||
providerSlug: string
|
||||
providerName: string
|
||||
providerIconUrl: string | null
|
||||
oidcSubject: string
|
||||
oidcIssuer: string
|
||||
linkedAt: string
|
||||
}
|
||||
|
||||
const oidcIdentity = ref<OidcIdentity | null>(null)
|
||||
const linkedIdentities = ref<LinkedIdentity[]>([])
|
||||
const oidcProviders = ref<OidcProviderPublic[]>([])
|
||||
const oidcIdentityLoading = ref(false)
|
||||
const unlinkPassword = ref('')
|
||||
const unlinkDialogOpen = ref(false)
|
||||
const unlinkTarget = ref<LinkedIdentity | null>(null)
|
||||
const unlinking = ref(false)
|
||||
const linkingOidc = ref(false)
|
||||
const linkingSlug = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
oidcIdentityLoading.value = true
|
||||
try {
|
||||
const res = await api('/api/auth/oidc/identity')
|
||||
if (res.ok) {
|
||||
oidcIdentity.value = await res.json()
|
||||
}
|
||||
const [identitiesRes, providersRes] = await Promise.all([
|
||||
api('/api/v1/auth/oidc/identities'),
|
||||
fetch('/api/v1/app-settings/oidc/providers/public'),
|
||||
])
|
||||
if (identitiesRes.ok) linkedIdentities.value = await identitiesRes.json()
|
||||
if (providersRes.ok) oidcProviders.value = await providersRes.json()
|
||||
} finally {
|
||||
oidcIdentityLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function initiateOidcLink() {
|
||||
linkingOidc.value = true
|
||||
function availableForLinking(): OidcProviderPublic[] {
|
||||
const linkedSlugs = new Set(linkedIdentities.value.map((i) => i.providerSlug))
|
||||
return oidcProviders.value.filter((p) => p.enabled && !linkedSlugs.has(p.slug))
|
||||
}
|
||||
|
||||
async function initiateOidcLink(provider: OidcProviderPublic) {
|
||||
linkingSlug.value = provider.slug
|
||||
try {
|
||||
const stateRes = await api('/api/auth/oidc/link-state', { method: 'POST' })
|
||||
if (!stateRes.ok) throw new Error('Failed to generate link state')
|
||||
const { state, authorizationEndpoint } = await stateRes.json()
|
||||
const stateRes = await api(`/api/v1/auth/oidc/${provider.slug}/link-state`, { method: 'POST' })
|
||||
if (!stateRes.ok) {
|
||||
const err = await stateRes.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to generate link state')
|
||||
}
|
||||
const { state, authorizationEndpoint, clientId, scopes } = await stateRes.json()
|
||||
const nonce = crypto.randomUUID()
|
||||
const { codeVerifier, codeChallenge } = await generatePkce()
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: '',
|
||||
client_id: clientId,
|
||||
redirect_uri: `${window.location.origin}/oauth2-callback`,
|
||||
scope: 'openid profile email',
|
||||
scope: scopes,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce: crypto.randomUUID(),
|
||||
nonce,
|
||||
})
|
||||
sessionStorage.setItem(`oidc_pkce_${state}`, JSON.stringify({ codeVerifier, nonce, state }))
|
||||
sessionStorage.setItem('oidc_link_pending', '1')
|
||||
window.location.href = `${authorizationEndpoint}?${params.toString()}`
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start OIDC link')
|
||||
linkingOidc.value = false
|
||||
linkingSlug.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUnlink() {
|
||||
if (!unlinkPassword.value) return
|
||||
if (!unlinkPassword.value || !unlinkTarget.value) return
|
||||
unlinking.value = true
|
||||
try {
|
||||
const res = await api('/api/auth/oidc/identity', {
|
||||
const res = await api(`/api/v1/auth/oidc/identities/${unlinkTarget.value.providerId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: unlinkPassword.value }),
|
||||
@@ -189,10 +214,11 @@ async function confirmUnlink() {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to unlink')
|
||||
}
|
||||
oidcIdentity.value = { oidcSubject: null, oidcIssuer: null }
|
||||
linkedIdentities.value = linkedIdentities.value.filter((i) => i.providerId !== unlinkTarget.value!.providerId)
|
||||
unlinkDialogOpen.value = false
|
||||
unlinkPassword.value = ''
|
||||
toast.success('OIDC identity unlinked')
|
||||
unlinkTarget.value = null
|
||||
toast.success('Identity unlinked')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to unlink identity')
|
||||
} finally {
|
||||
@@ -200,7 +226,8 @@ async function confirmUnlink() {
|
||||
}
|
||||
}
|
||||
|
||||
function openUnlinkDialog() {
|
||||
function openUnlinkDialog(identity: LinkedIdentity) {
|
||||
unlinkTarget.value = identity
|
||||
unlinkPassword.value = ''
|
||||
unlinkDialogOpen.value = true
|
||||
}
|
||||
@@ -208,6 +235,7 @@ function openUnlinkDialog() {
|
||||
function closeUnlinkDialog() {
|
||||
unlinkDialogOpen.value = false
|
||||
unlinkPassword.value = ''
|
||||
unlinkTarget.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -346,34 +374,53 @@ function closeUnlinkDialog() {
|
||||
<section class="rounded-xl border border-border bg-card p-4 md:p-5 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h2 class="text-sm font-semibold text-foreground">Connected Account</h2>
|
||||
<h2 class="text-sm font-semibold text-foreground">Connected Accounts</h2>
|
||||
</div>
|
||||
<div v-if="oidcIdentity?.oidcSubject">
|
||||
<p class="text-sm text-muted-foreground">Your account is linked to an external OIDC provider.</p>
|
||||
<div class="mt-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs font-mono text-muted-foreground space-y-0.5">
|
||||
<div><span class="text-foreground font-medium">Issuer:</span> {{ oidcIdentity.oidcIssuer }}</div>
|
||||
<div><span class="text-foreground font-medium">Subject:</span> {{ oidcIdentity.oidcSubject }}</div>
|
||||
|
||||
<!-- Linked identities -->
|
||||
<div v-if="linkedIdentities.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="identity in linkedIdentities"
|
||||
:key="identity.id"
|
||||
class="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<img v-if="identity.providerIconUrl" :src="identity.providerIconUrl" alt="" class="h-5 w-5 shrink-0 rounded object-contain" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ identity.providerName }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ identity.oidcSubject }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/5 transition-colors"
|
||||
@click="openUnlinkDialog(identity)"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-md border border-destructive/40 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/5 transition-colors"
|
||||
@click="openUnlinkDialog"
|
||||
>
|
||||
Unlink identity
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="text-sm text-muted-foreground">No external OIDC account linked. You can link your account to an SSO provider.</p>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="linkingOidc"
|
||||
class="mt-3 flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
@click="initiateOidcLink"
|
||||
>
|
||||
<Link class="h-3 w-3" />
|
||||
{{ linkingOidc ? 'Redirecting...' : 'Link OIDC account' }}
|
||||
</button>
|
||||
|
||||
<!-- Link new provider -->
|
||||
<div v-if="availableForLinking().length > 0" class="space-y-2">
|
||||
<p class="text-xs text-muted-foreground">Link additional providers:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="provider in availableForLinking()"
|
||||
:key="provider.slug"
|
||||
type="button"
|
||||
:disabled="linkingSlug !== null"
|
||||
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
@click="initiateOidcLink(provider)"
|
||||
>
|
||||
<img v-if="provider.iconUrl" :src="provider.iconUrl" alt="" class="h-3 w-3 shrink-0 object-contain" />
|
||||
<Link class="h-3 w-3" />
|
||||
{{ linkingSlug === provider.slug ? 'Redirecting...' : `Link ${provider.displayName}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="linkedIdentities.length === 0 && availableForLinking().length === 0" class="text-sm text-muted-foreground">
|
||||
No OIDC providers are configured. Ask an administrator to set up SSO.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -407,10 +454,8 @@ function closeUnlinkDialog() {
|
||||
<div v-if="unlinkDialogOpen" class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4" @click.self="closeUnlinkDialog">
|
||||
<button class="absolute inset-0 bg-black/45" @click="closeUnlinkDialog" />
|
||||
<div class="relative w-full rounded-t-xl border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-xl md:p-5">
|
||||
<p class="text-base font-semibold text-foreground">Unlink OIDC identity?</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Enter your password to confirm. After unlinking you can only log in with username and password.
|
||||
</p>
|
||||
<p class="text-base font-semibold text-foreground">Unlink {{ unlinkTarget?.providerName ?? 'OIDC' }} identity?</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">Enter your password to confirm. You will no longer be able to sign in with this provider.</p>
|
||||
<input
|
||||
v-model="unlinkPassword"
|
||||
type="password"
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { generatePkce } from '@/features/auth/composables/useOidc'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Permission, PERMISSION_LABELS } from '@projectx/types'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import { ArrowLeft, Plus, ShieldCheck, Trash2 } from 'lucide-vue-next'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
interface OidcConfig {
|
||||
interface ProviderSummary {
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
issuerUri: string
|
||||
iconUrl: string | null
|
||||
displayOrder: number
|
||||
}
|
||||
|
||||
interface ProviderDetail {
|
||||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
providerName: string
|
||||
issuerUri: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
scopes: string
|
||||
iconUrl?: string
|
||||
iconUrl: string | null
|
||||
claimMapping: { username: string; name: string; email: string; groups: string }
|
||||
autoProvision: { enabled: boolean; allowLocalLinking: boolean; defaultPermissionNames: string[] }
|
||||
displayOrder: number
|
||||
}
|
||||
|
||||
interface OidcTestResult {
|
||||
@@ -41,9 +55,15 @@ interface GroupMapping {
|
||||
|
||||
const ALL_PERMISSIONS = Object.values(Permission)
|
||||
|
||||
type ViewMode = 'list' | 'create' | 'edit'
|
||||
const viewMode = ref<ViewMode>('list')
|
||||
const providers = ref<ProviderSummary[]>([])
|
||||
const loading = ref(true)
|
||||
const editingSlug = ref<string | null>(null)
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const deleting = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const testResult = ref<OidcTestResult | null>(null)
|
||||
const showTestDetails = ref(false)
|
||||
@@ -54,79 +74,178 @@ const groupMappings = ref<GroupMapping[]>([])
|
||||
const newMapping = reactive({ oidcGroupClaim: '', permissionName: ALL_PERMISSIONS[0] })
|
||||
const addingMapping = ref(false)
|
||||
|
||||
const form = reactive<OidcConfig>({
|
||||
enabled: false,
|
||||
providerName: '',
|
||||
const emptyForm = (): ProviderDetail => ({
|
||||
id: 0,
|
||||
slug: '',
|
||||
displayName: '',
|
||||
enabled: true,
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
scopes: 'openid profile email',
|
||||
iconUrl: '',
|
||||
iconUrl: null,
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
autoProvision: { enabled: false, allowLocalLinking: false, defaultPermissionNames: [] },
|
||||
displayOrder: 0,
|
||||
})
|
||||
|
||||
const form = reactive<ProviderDetail>(emptyForm())
|
||||
const defaultPermissionSet = computed(() => new Set(form.autoProvision.defaultPermissionNames))
|
||||
|
||||
onMounted(async () => {
|
||||
const stored = sessionStorage.getItem('oidc_preview_claims')
|
||||
const previewSlug = sessionStorage.getItem('oidc_preview_pending')
|
||||
let restoredClaims: { raw: Record<string, unknown>; mapped: Record<string, unknown> } | null = null
|
||||
if (stored) {
|
||||
try {
|
||||
previewClaims.value = JSON.parse(stored)
|
||||
restoredClaims = JSON.parse(stored)
|
||||
} catch {
|
||||
// ignore
|
||||
/* ignore */
|
||||
}
|
||||
sessionStorage.removeItem('oidc_preview_claims')
|
||||
sessionStorage.removeItem('oidc_preview_pending')
|
||||
}
|
||||
|
||||
try {
|
||||
const [configRes, mappingsRes] = await Promise.all([api('/api/v1/app-settings/oidc'), api('/api/v1/app-settings/oidc/group-mappings')])
|
||||
if (configRes.ok) {
|
||||
const data = await configRes.json()
|
||||
Object.assign(form, data)
|
||||
form.clientSecret = ''
|
||||
}
|
||||
if (mappingsRes.ok) {
|
||||
groupMappings.value = await mappingsRes.json()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
await loadProviders()
|
||||
if (previewSlug && restoredClaims) {
|
||||
await startEdit(previewSlug)
|
||||
previewClaims.value = restoredClaims
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
async function loadProviders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/oidc/providers')
|
||||
if (res.ok) providers.value = await res.json()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
groupMappings.value = []
|
||||
testResult.value = null
|
||||
saveError.value = null
|
||||
previewClaims.value = null
|
||||
viewMode.value = 'create'
|
||||
}
|
||||
|
||||
async function startEdit(slug: string) {
|
||||
loading.value = true
|
||||
editingSlug.value = slug
|
||||
testResult.value = null
|
||||
saveError.value = null
|
||||
previewClaims.value = null
|
||||
try {
|
||||
const [providerRes, mappingsRes] = await Promise.all([
|
||||
api(`/api/v1/app-settings/oidc/providers/${slug}`),
|
||||
api(`/api/v1/app-settings/oidc/providers/${slug}/group-mappings`),
|
||||
])
|
||||
if (!providerRes.ok) throw new Error('Failed to load provider')
|
||||
const data = await providerRes.json()
|
||||
Object.assign(form, data)
|
||||
form.clientSecret = ''
|
||||
if (mappingsRes.ok) groupMappings.value = await mappingsRes.json()
|
||||
viewMode.value = 'edit'
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load provider')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
viewMode.value = 'list'
|
||||
editingSlug.value = null
|
||||
loadProviders()
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
saveError.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Partial<OidcConfig> = { ...form }
|
||||
if (!body.clientSecret) delete body.clientSecret
|
||||
if (viewMode.value === 'create') {
|
||||
const res = await api('/api/v1/app-settings/oidc/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: form.slug,
|
||||
displayName: form.displayName,
|
||||
enabled: form.enabled,
|
||||
issuerUri: form.issuerUri,
|
||||
clientId: form.clientId,
|
||||
clientSecret: form.clientSecret,
|
||||
scopes: form.scopes,
|
||||
iconUrl: form.iconUrl || null,
|
||||
claimMapping: form.claimMapping,
|
||||
autoProvision: form.autoProvision,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to create provider')
|
||||
}
|
||||
toast.success('Provider created')
|
||||
backToList()
|
||||
} else {
|
||||
const body: Record<string, unknown> = {
|
||||
displayName: form.displayName,
|
||||
enabled: form.enabled,
|
||||
issuerUri: form.issuerUri,
|
||||
clientId: form.clientId,
|
||||
scopes: form.scopes,
|
||||
iconUrl: form.iconUrl || null,
|
||||
claimMapping: form.claimMapping,
|
||||
autoProvision: form.autoProvision,
|
||||
}
|
||||
if (form.clientSecret) body.clientSecret = form.clientSecret
|
||||
|
||||
const res = await api('/api/v1/app-settings/oidc', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
|
||||
const res = await api(`/api/v1/app-settings/oidc/providers/${editingSlug.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
|
||||
}
|
||||
toast.success('Provider saved')
|
||||
}
|
||||
toast.success('OIDC settings saved')
|
||||
} catch (err) {
|
||||
saveError.value = err instanceof Error ? err.message : 'Failed to save settings'
|
||||
saveError.value = err instanceof Error ? err.message : 'Failed to save'
|
||||
toast.error(saveError.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider() {
|
||||
if (!editingSlug.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/app-settings/oidc/providers/${editingSlug.value}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to delete')
|
||||
}
|
||||
toast.success('Provider deleted')
|
||||
backToList()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete provider')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testResult.value = null
|
||||
showTestDetails.value = false
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await api(`/api/v1/app-settings/oidc/test?issuerUri=${encodeURIComponent(form.issuerUri)}`, { method: 'POST' })
|
||||
const data: OidcTestResult = await res.json()
|
||||
testResult.value = data
|
||||
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()
|
||||
} catch {
|
||||
testResult.value = { success: false, error: 'Request failed' }
|
||||
} finally {
|
||||
@@ -135,22 +254,28 @@ async function testConnection() {
|
||||
}
|
||||
|
||||
async function previewOidcClaims() {
|
||||
if (!editingSlug.value) return
|
||||
previewing.value = true
|
||||
previewClaims.value = null
|
||||
try {
|
||||
const stateRes = await api('/api/auth/oidc/preview-state', { method: 'POST' })
|
||||
const stateRes = await api(`/api/v1/auth/oidc/${editingSlug.value}/preview-state`, { method: 'POST' })
|
||||
if (!stateRes.ok) throw new Error('Failed to generate preview state')
|
||||
const { state, authorizationEndpoint } = await stateRes.json()
|
||||
const nonce = crypto.randomUUID()
|
||||
const { codeVerifier, codeChallenge } = await generatePkce()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: form.clientId,
|
||||
redirect_uri: `${window.location.origin}/oauth2-callback`,
|
||||
scope: form.scopes,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce: crypto.randomUUID(),
|
||||
nonce,
|
||||
})
|
||||
sessionStorage.setItem('oidc_preview_pending', '1')
|
||||
sessionStorage.setItem(`oidc_pkce_${state}`, JSON.stringify({ codeVerifier, nonce, state }))
|
||||
sessionStorage.setItem('oidc_preview_pending', editingSlug.value)
|
||||
window.location.href = `${authorizationEndpoint}?${params.toString()}`
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start preview')
|
||||
@@ -172,10 +297,10 @@ function toggleDefaultPermission(permission: Permission) {
|
||||
}
|
||||
|
||||
async function addGroupMapping() {
|
||||
if (!newMapping.oidcGroupClaim.trim()) return
|
||||
if (!newMapping.oidcGroupClaim.trim() || !editingSlug.value) return
|
||||
addingMapping.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/oidc/group-mappings', {
|
||||
const res = await api(`/api/v1/app-settings/oidc/providers/${editingSlug.value}/group-mappings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oidcGroupClaim: newMapping.oidcGroupClaim.trim(), permissionName: newMapping.permissionName }),
|
||||
@@ -196,8 +321,9 @@ async function addGroupMapping() {
|
||||
}
|
||||
|
||||
async function deleteGroupMapping(id: number) {
|
||||
if (!editingSlug.value) return
|
||||
try {
|
||||
const res = await api(`/api/v1/app-settings/oidc/group-mappings/${id}`, { method: 'DELETE' })
|
||||
const res = await api(`/api/v1/app-settings/oidc/providers/${editingSlug.value}/group-mappings/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to delete')
|
||||
groupMappings.value = groupMappings.value.filter((m) => m.id !== id)
|
||||
toast.success('Group mapping removed')
|
||||
@@ -208,264 +334,383 @@ async function deleteGroupMapping(id: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageHeader class="hidden md:flex" title="OIDC / SSO" subtitle="Configure an OpenID Connect provider for single sign-on." />
|
||||
<div class="md:hidden px-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-foreground">OIDC / SSO</h1>
|
||||
<p
|
||||
class="mt-1 text-sm text-muted-foreground leading-5 overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]"
|
||||
>
|
||||
Configure an OpenID Connect provider for single sign-on.
|
||||
</p>
|
||||
</div>
|
||||
<!-- List view -->
|
||||
<template v-if="viewMode === 'list'">
|
||||
<SettingsPageHeader class="hidden md:flex" title="OIDC / SSO" subtitle="Manage OpenID Connect providers for single sign-on." />
|
||||
<div class="md:hidden px-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-foreground">OIDC / SSO</h1>
|
||||
<p
|
||||
class="mt-1 text-sm text-muted-foreground leading-5 overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]"
|
||||
>
|
||||
Manage OpenID Connect providers for single sign-on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-5 md:mt-0 text-sm text-muted-foreground">Loading...</div>
|
||||
<div v-if="loading" class="mt-5 md:mt-0 text-sm text-muted-foreground">Loading...</div>
|
||||
|
||||
<form v-else class="mt-5 md:mt-0 space-y-6" @submit.prevent="save">
|
||||
<!-- Enable -->
|
||||
<div>
|
||||
<p class="settings-group-label">Status</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Enable OIDC</p>
|
||||
<p class="settings-hint">Show SSO login button and allow OIDC authentication.</p>
|
||||
<div v-else class="mt-5 md:mt-0 space-y-4">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="settings-group-label mb-0">Providers</p>
|
||||
<button type="button" class="settings-btn-primary" @click="startCreate">
|
||||
<Plus :size="12" />
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
<div class="border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<div v-if="providers.length > 0" class="divide-y divide-border">
|
||||
<button
|
||||
v-for="provider in providers"
|
||||
:key="provider.slug"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-4 py-4 bg-card text-left hover:bg-muted/30 transition-colors md:px-5"
|
||||
@click="startEdit(provider.slug)"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-muted-foreground shrink-0 border border-border overflow-hidden"
|
||||
>
|
||||
<img v-if="provider.iconUrl" :src="provider.iconUrl" alt="" class="h-5 w-5 object-contain" />
|
||||
<ShieldCheck v-else :size="16" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ provider.displayName }}</p>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ provider.slug }} - {{ provider.issuerUri }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
:class="provider.enabled ? 'bg-green-500/10 text-green-600' : 'bg-muted text-muted-foreground'"
|
||||
>
|
||||
{{ provider.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="px-5 py-10 bg-card text-center">
|
||||
<div class="w-10 h-10 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
<ShieldCheck :size="18" class="text-muted-foreground/70" />
|
||||
</div>
|
||||
<p class="text-sm font-medium text-foreground">No providers yet</p>
|
||||
<p class="text-xs text-muted-foreground mt-1 max-w-[240px] mx-auto">Add an OIDC provider to enable single sign-on for your users.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.enabled" class="self-start md:self-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Provider -->
|
||||
<div>
|
||||
<p class="settings-group-label">Provider</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0">
|
||||
<p class="settings-label">Provider Name</p>
|
||||
<p class="settings-hint">Shown on the login button.</p>
|
||||
</div>
|
||||
<input v-model="form.providerName" type="text" placeholder="Authentik" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0">
|
||||
<p class="settings-label">Icon URL</p>
|
||||
<p class="settings-hint">Optional logo shown next to the login button.</p>
|
||||
</div>
|
||||
<div class="flex w-full items-center gap-2 md:w-72">
|
||||
<img v-if="form.iconUrl" :src="form.iconUrl" alt="icon preview" class="h-5 w-5 shrink-0 rounded object-contain" />
|
||||
<input v-model="form.iconUrl" type="url" placeholder="https://example.com/logo.svg" class="input-field min-w-0 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-start md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0 md:pt-0.5">
|
||||
<p class="settings-label">Issuer URI</p>
|
||||
<p class="settings-hint">The provider's base URL.</p>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-start gap-2 md:w-auto md:items-end">
|
||||
<div class="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
|
||||
<input v-model="form.issuerUri" type="url" placeholder="https://accounts.example.com" class="input-field w-full md:w-80" />
|
||||
<button
|
||||
type="button"
|
||||
:disabled="testing || !form.issuerUri"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors shrink-0 focus:outline-none focus:ring-2 focus:ring-primary/50 md:w-auto md:py-1.5"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ testing ? 'Testing...' : 'Test' }}
|
||||
</button>
|
||||
<!-- Create / Edit view -->
|
||||
<template v-else>
|
||||
<div class="hidden md:flex items-center gap-3 mb-4">
|
||||
<button type="button" class="p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors" @click="backToList">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</button>
|
||||
<div
|
||||
v-if="viewMode === 'edit' && form.iconUrl"
|
||||
class="w-10 h-10 rounded-lg bg-muted flex items-center justify-center shrink-0 border border-border overflow-hidden"
|
||||
>
|
||||
<img :src="form.iconUrl" alt="" class="h-7 w-7 object-contain" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ viewMode === 'create' ? 'Add Provider' : form.displayName || 'Edit Provider' }}</h2>
|
||||
<p class="text-sm text-muted-foreground">{{ viewMode === 'create' ? 'Configure a new OIDC provider.' : `Editing ${editingSlug}` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:hidden px-1 mb-4">
|
||||
<button type="button" class="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-2" @click="backToList">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-foreground">
|
||||
{{ viewMode === 'create' ? 'Add Provider' : form.displayName || 'Edit Provider' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
|
||||
|
||||
<form v-else class="space-y-6" @submit.prevent="saveProvider">
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<p class="settings-group-label">Status</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-sm">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Enable provider</p>
|
||||
<p class="settings-hint">Show this provider on the login page.</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="w-full rounded-md border px-3 py-2 text-xs md:w-auto"
|
||||
:class="
|
||||
testResult.success ? 'border-green-500/30 text-green-600 bg-green-500/5' : 'border-destructive/30 text-destructive bg-destructive/5'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{{ testResult.success ? `Connected - ${testResult.issuer ?? ''}` : (testResult.error ?? 'Connection failed') }}</span>
|
||||
<ToggleSwitch v-model="form.enabled" class="self-start md:self-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identity -->
|
||||
<div>
|
||||
<p class="settings-group-label">Provider Identity</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-sm">
|
||||
<div
|
||||
v-if="viewMode === 'create'"
|
||||
class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4"
|
||||
>
|
||||
<div class="min-w-0 md:shrink-0">
|
||||
<p class="settings-label">Slug</p>
|
||||
<p class="settings-hint">Unique identifier (lowercase, hyphens). Cannot be changed later.</p>
|
||||
</div>
|
||||
<input v-model="form.slug" type="text" placeholder="keycloak" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0">
|
||||
<p class="settings-label">Display Name</p>
|
||||
<p class="settings-hint">Shown on the login button.</p>
|
||||
</div>
|
||||
<input v-model="form.displayName" type="text" placeholder="Keycloak" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0">
|
||||
<p class="settings-label">Icon URL</p>
|
||||
<p class="settings-hint">Optional logo shown next to the login button.</p>
|
||||
</div>
|
||||
<div class="flex w-full items-center gap-2 md:w-72">
|
||||
<img v-if="form.iconUrl" :src="form.iconUrl" alt="icon preview" class="h-5 w-5 shrink-0 rounded object-contain" />
|
||||
<input v-model="form.iconUrl" type="url" placeholder="https://example.com/logo.svg" class="input-field min-w-0 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection -->
|
||||
<div>
|
||||
<p class="settings-group-label">Connection</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-sm">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-start md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<div class="min-w-0 md:shrink-0 md:pt-0.5">
|
||||
<p class="settings-label">Issuer URI</p>
|
||||
<p class="settings-hint">The provider's base URL.</p>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-start gap-2 md:w-auto md:items-end">
|
||||
<div class="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
|
||||
<input v-model="form.issuerUri" type="url" placeholder="https://accounts.example.com" class="input-field w-full md:w-80" />
|
||||
<button
|
||||
v-if="testResult.success"
|
||||
type="button"
|
||||
class="shrink-0 underline underline-offset-2 opacity-70 hover:opacity-100"
|
||||
@click="toggleTestDetails"
|
||||
:disabled="testing || !form.issuerUri"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors shrink-0 focus:outline-none focus:ring-2 focus:ring-primary/50 md:w-auto md:py-1.5"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ showTestDetails ? 'Hide' : 'Details' }}
|
||||
{{ testing ? 'Testing...' : 'Test' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="testResult.success && showTestDetails" class="mt-2 space-y-0.5 border-t border-green-500/20 pt-2 font-mono text-[10px]">
|
||||
<div v-if="testResult.tokenEndpoint">token: {{ testResult.tokenEndpoint }}</div>
|
||||
<div v-if="testResult.userinfoEndpoint">userinfo: {{ testResult.userinfoEndpoint }}</div>
|
||||
<div v-if="testResult.jwksUri">jwks: {{ testResult.jwksUri }}</div>
|
||||
<div v-if="testResult.codeChallengeMethodsSupported?.length">pkce: {{ testResult.codeChallengeMethodsSupported.join(', ') }}</div>
|
||||
<div v-if="testResult.supportedScopes?.length">scopes: {{ testResult.supportedScopes.join(', ') }}</div>
|
||||
<div>backchannel logout: {{ testResult.backchannelLogoutSupported ? 'supported' : 'not supported' }}</div>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="w-full rounded-md border px-3 py-2 text-xs md:w-auto"
|
||||
:class="
|
||||
testResult.success ? 'border-green-500/30 text-green-600 bg-green-500/5' : 'border-destructive/30 text-destructive bg-destructive/5'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span>{{ testResult.success ? `Connected - ${testResult.issuer ?? ''}` : (testResult.error ?? 'Connection failed') }}</span>
|
||||
<button
|
||||
v-if="testResult.success"
|
||||
type="button"
|
||||
class="shrink-0 underline underline-offset-2 opacity-70 hover:opacity-100"
|
||||
@click="toggleTestDetails"
|
||||
>
|
||||
{{ showTestDetails ? 'Hide' : 'Details' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="testResult.success && showTestDetails" class="mt-2 space-y-0.5 border-t border-green-500/20 pt-2 font-mono text-[10px]">
|
||||
<div v-if="testResult.tokenEndpoint">token: {{ testResult.tokenEndpoint }}</div>
|
||||
<div v-if="testResult.userinfoEndpoint">userinfo: {{ testResult.userinfoEndpoint }}</div>
|
||||
<div v-if="testResult.jwksUri">jwks: {{ testResult.jwksUri }}</div>
|
||||
<div v-if="testResult.codeChallengeMethodsSupported?.length">pkce: {{ testResult.codeChallengeMethodsSupported.join(', ') }}</div>
|
||||
<div v-if="testResult.supportedScopes?.length">scopes: {{ testResult.supportedScopes.join(', ') }}</div>
|
||||
<div>backchannel logout: {{ testResult.backchannelLogoutSupported ? 'supported' : 'not supported' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Client ID</p>
|
||||
<input v-model="form.clientId" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Client Secret</p>
|
||||
<input
|
||||
v-model="form.clientSecret"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep existing"
|
||||
autocomplete="new-password"
|
||||
class="input-field w-full md:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Scopes</p>
|
||||
<input v-model="form.scopes" type="text" class="input-field w-full md:w-72" />
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Client ID</p>
|
||||
<input v-model="form.clientId" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Client Secret</p>
|
||||
<input
|
||||
v-model="form.clientSecret"
|
||||
type="password"
|
||||
:placeholder="viewMode === 'edit' ? 'Leave blank to keep existing' : ''"
|
||||
autocomplete="new-password"
|
||||
class="input-field w-full md:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Scopes</p>
|
||||
<input v-model="form.scopes" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claim mapping -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="settings-group-label">Claim Mapping</p>
|
||||
<button
|
||||
v-if="form.enabled && form.clientId && form.issuerUri"
|
||||
type="button"
|
||||
:disabled="previewing"
|
||||
class="text-xs font-medium text-primary underline-offset-2 hover:underline disabled:opacity-50"
|
||||
@click="previewOidcClaims"
|
||||
>
|
||||
{{ previewing ? 'Redirecting...' : 'Preview claims' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="previewClaims" class="mb-3 rounded-md border border-border bg-card p-3 text-xs">
|
||||
<p class="font-medium text-foreground mb-1.5">Mapped claims</p>
|
||||
<pre class="text-muted-foreground overflow-auto rounded bg-muted/40 p-2 text-[10px]">{{ JSON.stringify(previewClaims.mapped, null, 2) }}</pre>
|
||||
<p class="mt-2 font-medium text-foreground mb-1.5">Raw claims</p>
|
||||
<pre class="text-muted-foreground overflow-auto rounded bg-muted/40 p-2 text-[10px] max-h-40">{{
|
||||
JSON.stringify(previewClaims.raw, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Username claim</p>
|
||||
<input v-model="form.claimMapping.username" type="text" class="input-field w-full md:w-72" />
|
||||
<!-- Claim mapping -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="settings-group-label">Claim Mapping</p>
|
||||
<button
|
||||
v-if="viewMode === 'edit' && form.enabled && form.clientId && form.issuerUri"
|
||||
type="button"
|
||||
:disabled="previewing"
|
||||
class="text-xs font-medium text-primary underline-offset-2 hover:underline disabled:opacity-50"
|
||||
@click="previewOidcClaims"
|
||||
>
|
||||
{{ previewing ? 'Redirecting...' : 'Preview claims' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Name claim</p>
|
||||
<input v-model="form.claimMapping.name" type="text" class="input-field w-full md:w-72" />
|
||||
<div v-if="previewClaims" class="mb-3 rounded-md border border-border bg-card p-3 text-xs">
|
||||
<p class="font-medium text-foreground mb-1.5">Mapped claims</p>
|
||||
<pre class="text-muted-foreground overflow-auto rounded bg-muted/40 p-2 text-[11px]">{{
|
||||
JSON.stringify(previewClaims.mapped, null, 2)
|
||||
}}</pre>
|
||||
<p class="mt-2 font-medium text-foreground mb-1.5">Raw claims</p>
|
||||
<pre class="text-muted-foreground overflow-auto rounded bg-muted/40 p-2 text-[11px] max-h-40">{{
|
||||
JSON.stringify(previewClaims.raw, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Email claim</p>
|
||||
<input v-model="form.claimMapping.email" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Groups claim</p>
|
||||
<input v-model="form.claimMapping.groups" type="text" class="input-field w-full md:w-72" />
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-sm">
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Username claim</p>
|
||||
<input v-model="form.claimMapping.username" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Name claim</p>
|
||||
<input v-model="form.claimMapping.name" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Email claim</p>
|
||||
<input v-model="form.claimMapping.email" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:gap-8 md:px-5 md:py-4">
|
||||
<p class="settings-label md:shrink-0">Groups claim</p>
|
||||
<input v-model="form.claimMapping.groups" type="text" class="input-field w-full md:w-72" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-provisioning -->
|
||||
<div>
|
||||
<p class="settings-group-label">Auto-Provisioning</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Auto-provision users</p>
|
||||
<p class="settings-hint">Create accounts on first OIDC login if user does not exist.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.autoProvision.enabled" class="self-start md:self-auto" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Allow local account linking</p>
|
||||
<p class="settings-hint">Link OIDC identity to an existing local account by username match.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.autoProvision.allowLocalLinking" class="self-start md:self-auto" />
|
||||
</div>
|
||||
<!-- P1-4: Default permissions for auto-provisioned users -->
|
||||
<div class="px-4 py-3.5 bg-card md:px-5 md:py-4">
|
||||
<div class="mb-3">
|
||||
<p class="settings-label">Default permissions</p>
|
||||
<p class="settings-hint">Permissions granted to new users on first OIDC login.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label v-for="perm in ALL_PERMISSIONS" :key="perm" class="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="defaultPermissionSet.has(perm)"
|
||||
class="h-4 w-4 rounded border-input accent-primary cursor-pointer"
|
||||
@change="toggleDefaultPermission(perm)"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{{ PERMISSION_LABELS[perm] }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Mappings (P1-3) -->
|
||||
<div>
|
||||
<p class="settings-group-label">Group Mappings</p>
|
||||
<p class="mb-3 text-xs text-muted-foreground">Map OIDC group claims to ProjectX permissions. Synced on every login.</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden bg-card">
|
||||
<!-- Existing mappings -->
|
||||
<div v-if="groupMappings.length > 0" class="divide-y divide-border">
|
||||
<div v-for="mapping in groupMappings" :key="mapping.id" class="flex items-center justify-between gap-3 px-4 py-3 md:px-5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ mapping.oidcGroupClaim }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ mapping.permissionName ? (PERMISSION_LABELS[mapping.permissionName as Permission] ?? mapping.permissionName) : 'No permission' }}
|
||||
</p>
|
||||
<!-- Auto-provisioning -->
|
||||
<div>
|
||||
<p class="settings-group-label">Auto-Provisioning</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-sm">
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Auto-provision users</p>
|
||||
<p class="settings-hint">Create accounts on first OIDC login if user does not exist.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="deleteGroupMapping(mapping.id)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<ToggleSwitch v-model="form.autoProvision.enabled" class="self-start md:self-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 py-4 text-sm text-muted-foreground md:px-5">No group mappings configured.</div>
|
||||
|
||||
<!-- Add new mapping -->
|
||||
<div class="border-t border-border px-4 py-3.5 md:px-5 md:py-4">
|
||||
<p class="settings-label mb-2">Add mapping</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input v-model="newMapping.oidcGroupClaim" type="text" placeholder="OIDC group claim (e.g. admins)" class="input-field flex-1 min-w-0" />
|
||||
<select v-model="newMapping.permissionName" class="input-field sm:w-52 shrink-0">
|
||||
<option v-for="perm in ALL_PERMISSIONS" :key="perm" :value="perm">
|
||||
{{ PERMISSION_LABELS[perm] }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="addingMapping || !newMapping.oidcGroupClaim.trim()"
|
||||
class="settings-btn-primary shrink-0 disabled:opacity-50"
|
||||
@click="addGroupMapping"
|
||||
>
|
||||
{{ addingMapping ? 'Adding...' : 'Add' }}
|
||||
</button>
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5 bg-card md:flex-row md:items-center md:justify-between md:px-5 md:py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="settings-label">Allow local account linking</p>
|
||||
<p class="settings-hint">Link OIDC identity to an existing local account by username match.</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="form.autoProvision.allowLocalLinking" class="self-start md:self-auto" />
|
||||
</div>
|
||||
<div class="px-4 py-3.5 bg-card md:px-5 md:py-4">
|
||||
<div class="mb-3">
|
||||
<p class="settings-label">Default permissions</p>
|
||||
<p class="settings-hint">Permissions granted to new users on first OIDC login.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label v-for="perm in ALL_PERMISSIONS" :key="perm" class="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="defaultPermissionSet.has(perm)"
|
||||
class="h-4 w-4 rounded border-input accent-primary cursor-pointer"
|
||||
@change="toggleDefaultPermission(perm)"
|
||||
/>
|
||||
<span class="text-sm text-foreground">{{ PERMISSION_LABELS[perm] }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<button type="submit" :disabled="saving" class="settings-btn-primary">
|
||||
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||
</button>
|
||||
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
|
||||
</div>
|
||||
<div class="md:hidden sticky bottom-2 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" :disabled="saving" class="settings-btn-primary w-full min-h-10 justify-center">
|
||||
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||
<!-- Group Mappings (only in edit mode) -->
|
||||
<div v-if="viewMode === 'edit'">
|
||||
<p class="settings-group-label">Group Mappings</p>
|
||||
<p class="mb-3 text-xs text-muted-foreground">Map OIDC group claims to ProjectX permissions. Synced on every login.</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden bg-card shadow-sm">
|
||||
<div v-if="groupMappings.length > 0" class="divide-y divide-border">
|
||||
<div v-for="mapping in groupMappings" :key="mapping.id" class="flex items-center justify-between gap-3 px-4 py-3 md:px-5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ mapping.oidcGroupClaim }}</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ mapping.permissionName ? (PERMISSION_LABELS[mapping.permissionName as Permission] ?? mapping.permissionName) : 'No permission' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="deleteGroupMapping(mapping.id)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 py-4 text-sm text-muted-foreground md:px-5">No group mappings configured.</div>
|
||||
|
||||
<div class="border-t border-border px-4 py-3.5 md:px-5 md:py-4">
|
||||
<p class="settings-label mb-2">Add mapping</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
v-model="newMapping.oidcGroupClaim"
|
||||
type="text"
|
||||
placeholder="OIDC group claim (e.g. admins)"
|
||||
class="input-field flex-1 min-w-0"
|
||||
/>
|
||||
<select v-model="newMapping.permissionName" class="input-field sm:w-52 shrink-0">
|
||||
<option v-for="perm in ALL_PERMISSIONS" :key="perm" :value="perm">
|
||||
{{ PERMISSION_LABELS[perm] }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="addingMapping || !newMapping.oidcGroupClaim.trim()"
|
||||
class="settings-btn-primary shrink-0 disabled:opacity-50"
|
||||
@click="addGroupMapping"
|
||||
>
|
||||
{{ addingMapping ? 'Adding...' : 'Add' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save + Delete -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<button type="submit" :disabled="saving" class="settings-btn-primary">
|
||||
{{ saving ? 'Saving...' : viewMode === 'create' ? 'Create Provider' : 'Save Changes' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="viewMode === 'edit'"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
class="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-1.5 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
|
||||
@click="deleteProvider"
|
||||
>
|
||||
{{ deleting ? 'Deleting...' : 'Delete Provider' }}
|
||||
</button>
|
||||
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
|
||||
</div>
|
||||
<p v-if="saveError" class="mt-1.5 text-xs text-destructive line-clamp-2">{{ saveError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
<div class="md:hidden sticky bottom-2 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" :disabled="saving" class="settings-btn-primary w-full min-h-10 justify-center">
|
||||
{{ saving ? 'Saving...' : viewMode === 'create' ? 'Create Provider' : 'Save Changes' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="viewMode === 'edit'"
|
||||
type="button"
|
||||
:disabled="deleting"
|
||||
class="shrink-0 min-h-10 rounded-md border border-destructive/30 bg-destructive/5 px-3 text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
|
||||
@click="deleteProvider"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="saveError" class="mt-1.5 text-xs text-destructive line-clamp-2">{{ saveError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -42,6 +42,41 @@ export interface OidcPublicConfig {
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface OidcProviderPublic {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
iconUrl?: string | null;
|
||||
clientId: string;
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
export interface OidcProviderConfig {
|
||||
id: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scopes: string;
|
||||
iconUrl?: string | null;
|
||||
claimMapping: OidcClaimMapping;
|
||||
autoProvision: OidcAutoProvision;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface OidcLinkedIdentity {
|
||||
id: number;
|
||||
providerId: number;
|
||||
providerSlug: string;
|
||||
providerName: string;
|
||||
providerIconUrl?: string | null;
|
||||
oidcSubject: string;
|
||||
oidcIssuer: string;
|
||||
linkedAt: string;
|
||||
}
|
||||
|
||||
export interface OidcClaimMapping {
|
||||
username: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthorAutoEnrichmentConfig, OidcBaseConfig } from '@projectx/types';
|
||||
import type { AuthorAutoEnrichmentConfig, OidcAutoProvision, OidcBaseConfig, OidcClaimMapping } from '@projectx/types';
|
||||
|
||||
export const APP_SETTING_KEYS = {
|
||||
ALLOW_REGISTRATION: 'allow_registration',
|
||||
@@ -39,6 +39,19 @@ export const DEFAULT_OIDC_CONFIG: OidcFullConfig = {
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
};
|
||||
|
||||
export const DEFAULT_CLAIM_MAPPING: OidcClaimMapping = {
|
||||
username: 'preferred_username',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
groups: 'groups',
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTO_PROVISION: OidcAutoProvision = {
|
||||
enabled: false,
|
||||
allowLocalLinking: false,
|
||||
defaultPermissionNames: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTHOR_ENRICHMENT_CONFIG: AuthorAutoEnrichmentConfig = {
|
||||
enabled: false,
|
||||
triggerOnImport: true,
|
||||
|
||||
@@ -560,15 +560,51 @@ CREATE TABLE "user_reading_daily_stats" (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_group_mappings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"oidc_group_claim" text NOT NULL,
|
||||
"permission_name" varchar(100),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_identities" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"oidc_subject" text NOT NULL,
|
||||
"oidc_issuer" text NOT NULL,
|
||||
"linked_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_permission_grants" (
|
||||
"user_id" integer NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"permission_name" varchar(100) NOT NULL,
|
||||
CONSTRAINT "oidc_permission_grants_user_id_provider_id_permission_name_pk" PRIMARY KEY("user_id","provider_id","permission_name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_providers" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(100) NOT NULL,
|
||||
"display_name" varchar(255) NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"issuer_uri" text NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"client_secret" text,
|
||||
"scopes" text DEFAULT 'openid profile email' NOT NULL,
|
||||
"icon_url" text,
|
||||
"claim_mapping" jsonb DEFAULT '{"username":"preferred_username","name":"name","email":"email","groups":"groups"}'::jsonb NOT NULL,
|
||||
"auto_provision" jsonb DEFAULT '{"enabled":false,"allowLocalLinking":false,"defaultPermissionNames":[]}'::jsonb NOT NULL,
|
||||
"display_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oidc_group_mappings_oidc_group_claim_unique" UNIQUE("oidc_group_claim")
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "oidc_providers_slug_unique" UNIQUE("slug"),
|
||||
CONSTRAINT "oidc_providers_issuer_uri_unique" UNIQUE("issuer_uri")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_sessions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"provider_id" integer,
|
||||
"oidc_subject" text NOT NULL,
|
||||
"oidc_issuer" text NOT NULL,
|
||||
"oidc_session_id" text,
|
||||
@@ -581,6 +617,7 @@ CREATE TABLE "oidc_sessions" (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_states" (
|
||||
"state" text PRIMARY KEY NOT NULL,
|
||||
"provider_id" integer NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"meta" text
|
||||
);
|
||||
@@ -873,7 +910,14 @@ ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_user_id_users_id
|
||||
ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_library_id_libraries_id_fk" FOREIGN KEY ("library_id") REFERENCES "public"."libraries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_group_mappings" ADD CONSTRAINT "oidc_group_mappings_provider_id_oidc_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."oidc_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_identities" ADD CONSTRAINT "oidc_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_identities" ADD CONSTRAINT "oidc_identities_provider_id_oidc_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."oidc_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_permission_grants" ADD CONSTRAINT "oidc_permission_grants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_permission_grants" ADD CONSTRAINT "oidc_permission_grants_provider_id_oidc_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."oidc_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_sessions" ADD CONSTRAINT "oidc_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_sessions" ADD CONSTRAINT "oidc_sessions_provider_id_oidc_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."oidc_providers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oidc_states" ADD CONSTRAINT "oidc_states_provider_id_oidc_providers_id_fk" FOREIGN KEY ("provider_id") REFERENCES "public"."oidc_providers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "opds_users" ADD CONSTRAINT "opds_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_devices" ADD CONSTRAINT "kobo_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kobo_library_snapshots" ADD CONSTRAINT "kobo_library_snapshots_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
@@ -991,10 +1035,17 @@ CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id
|
||||
CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint
|
||||
CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oidc_group_mappings_provider_claim_uidx" ON "oidc_group_mappings" USING btree ("provider_id","oidc_group_claim");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oidc_identities_provider_subject_uidx" ON "oidc_identities" USING btree ("provider_id","oidc_subject");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oidc_identities_user_provider_uidx" ON "oidc_identities" USING btree ("user_id","provider_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "oidc_identities_issuer_subject_uidx" ON "oidc_identities" USING btree ("oidc_issuer","oidc_subject");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_identities_user_id_idx" ON "oidc_identities" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_providers_enabled_order_idx" ON "oidc_providers" USING btree ("enabled","display_order");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_user_active_idx" ON "oidc_sessions" USING btree ("user_id") WHERE "oidc_sessions"."revoked" = false;--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree ("oidc_subject","oidc_issuer");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_expires_at_idx" ON "oidc_sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_sessions_provider_id_idx" ON "oidc_sessions" USING btree ("provider_id");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_states_expires_at_idx" ON "oidc_states" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "oidc_used_jtis_expires_at_idx" ON "oidc_used_jtis" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "opds_users_username_lower_uidx" ON "opds_users" USING btree (lower("username"));--> statement-breakpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "373242c5-f28a-4dd1-a03f-e6b0abec26c9",
|
||||
"id": "9d2f4780-40c3-4969-a269-081c3ec44766",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -5274,6 +5274,12 @@
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_group_claim": {
|
||||
"name": "oidc_group_claim",
|
||||
"type": "text",
|
||||
@@ -5294,14 +5300,380 @@
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_group_mappings_provider_claim_uidx": {
|
||||
"name": "oidc_group_mappings_provider_claim_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "oidc_group_claim",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"oidc_group_mappings_provider_id_oidc_providers_id_fk": {
|
||||
"name": "oidc_group_mappings_provider_id_oidc_providers_id_fk",
|
||||
"tableFrom": "oidc_group_mappings",
|
||||
"tableTo": "oidc_providers",
|
||||
"columnsFrom": ["provider_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_identities": {
|
||||
"name": "oidc_identities",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_subject": {
|
||||
"name": "oidc_subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_issuer": {
|
||||
"name": "oidc_issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"linked_at": {
|
||||
"name": "linked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_identities_provider_subject_uidx": {
|
||||
"name": "oidc_identities_provider_subject_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "oidc_subject",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"oidc_identities_user_provider_uidx": {
|
||||
"name": "oidc_identities_user_provider_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "provider_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"oidc_identities_issuer_subject_uidx": {
|
||||
"name": "oidc_identities_issuer_subject_uidx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "oidc_issuer",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "oidc_subject",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"oidc_identities_user_id_idx": {
|
||||
"name": "oidc_identities_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"oidc_identities_user_id_users_id_fk": {
|
||||
"name": "oidc_identities_user_id_users_id_fk",
|
||||
"tableFrom": "oidc_identities",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"oidc_identities_provider_id_oidc_providers_id_fk": {
|
||||
"name": "oidc_identities_provider_id_oidc_providers_id_fk",
|
||||
"tableFrom": "oidc_identities",
|
||||
"tableTo": "oidc_providers",
|
||||
"columnsFrom": ["provider_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_permission_grants": {
|
||||
"name": "oidc_permission_grants",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_name": {
|
||||
"name": "permission_name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"oidc_permission_grants_user_id_users_id_fk": {
|
||||
"name": "oidc_permission_grants_user_id_users_id_fk",
|
||||
"tableFrom": "oidc_permission_grants",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"oidc_permission_grants_provider_id_oidc_providers_id_fk": {
|
||||
"name": "oidc_permission_grants_provider_id_oidc_providers_id_fk",
|
||||
"tableFrom": "oidc_permission_grants",
|
||||
"tableTo": "oidc_providers",
|
||||
"columnsFrom": ["provider_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"oidc_permission_grants_user_id_provider_id_permission_name_pk": {
|
||||
"name": "oidc_permission_grants_user_id_provider_id_permission_name_pk",
|
||||
"columns": ["user_id", "provider_id", "permission_name"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_providers": {
|
||||
"name": "oidc_providers",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"issuer_uri": {
|
||||
"name": "issuer_uri",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_secret": {
|
||||
"name": "client_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scopes": {
|
||||
"name": "scopes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'openid profile email'"
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"claim_mapping": {
|
||||
"name": "claim_mapping",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"username\":\"preferred_username\",\"name\":\"name\",\"email\":\"email\",\"groups\":\"groups\"}'::jsonb"
|
||||
},
|
||||
"auto_provision": {
|
||||
"name": "auto_provision",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{\"enabled\":false,\"allowLocalLinking\":false,\"defaultPermissionNames\":[]}'::jsonb"
|
||||
},
|
||||
"display_order": {
|
||||
"name": "display_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_providers_enabled_order_idx": {
|
||||
"name": "oidc_providers_enabled_order_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "enabled",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "display_order",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"oidc_group_mappings_oidc_group_claim_unique": {
|
||||
"name": "oidc_group_mappings_oidc_group_claim_unique",
|
||||
"oidc_providers_slug_unique": {
|
||||
"name": "oidc_providers_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["oidc_group_claim"]
|
||||
"columns": ["slug"]
|
||||
},
|
||||
"oidc_providers_issuer_uri_unique": {
|
||||
"name": "oidc_providers_issuer_uri_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["issuer_uri"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -5324,6 +5696,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"oidc_subject": {
|
||||
"name": "oidc_subject",
|
||||
"type": "text",
|
||||
@@ -5442,6 +5820,21 @@
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"oidc_sessions_provider_id_idx": {
|
||||
"name": "oidc_sessions_provider_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "provider_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
@@ -5453,6 +5846,15 @@
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"oidc_sessions_provider_id_oidc_providers_id_fk": {
|
||||
"name": "oidc_sessions_provider_id_oidc_providers_id_fk",
|
||||
"tableFrom": "oidc_sessions",
|
||||
"tableTo": "oidc_providers",
|
||||
"columnsFrom": ["provider_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
@@ -5471,6 +5873,12 @@
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -5501,7 +5909,17 @@
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"foreignKeys": {
|
||||
"oidc_states_provider_id_oidc_providers_id_fk": {
|
||||
"name": "oidc_states_provider_id_oidc_providers_id_fk",
|
||||
"tableFrom": "oidc_states",
|
||||
"tableTo": "oidc_providers",
|
||||
"columnsFrom": ["provider_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1776150932210,
|
||||
"when": 1776162198181,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -1,12 +1,75 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, index, integer, pgTable, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const oidcProviders = pgTable(
|
||||
'oidc_providers',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
slug: varchar('slug', { length: 100 }).notNull().unique(),
|
||||
displayName: varchar('display_name', { length: 255 }).notNull(),
|
||||
enabled: boolean('enabled').notNull().default(false),
|
||||
issuerUri: text('issuer_uri').notNull().unique(),
|
||||
clientId: text('client_id').notNull(),
|
||||
clientSecret: text('client_secret'),
|
||||
scopes: text('scopes').notNull().default('openid profile email'),
|
||||
iconUrl: text('icon_url'),
|
||||
claimMapping: jsonb('claim_mapping').notNull().default({ username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' }),
|
||||
autoProvision: jsonb('auto_provision').notNull().default({ enabled: false, allowLocalLinking: false, defaultPermissionNames: [] }),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date()),
|
||||
},
|
||||
(t) => [index('oidc_providers_enabled_order_idx').on(t.enabled, t.displayOrder)],
|
||||
);
|
||||
|
||||
export const oidcIdentities = pgTable(
|
||||
'oidc_identities',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => oidcProviders.id, { onDelete: 'cascade' }),
|
||||
oidcSubject: text('oidc_subject').notNull(),
|
||||
oidcIssuer: text('oidc_issuer').notNull(),
|
||||
linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('oidc_identities_provider_subject_uidx').on(t.providerId, t.oidcSubject),
|
||||
uniqueIndex('oidc_identities_user_provider_uidx').on(t.userId, t.providerId),
|
||||
uniqueIndex('oidc_identities_issuer_subject_uidx').on(t.oidcIssuer, t.oidcSubject),
|
||||
index('oidc_identities_user_id_idx').on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const oidcPermissionGrants = pgTable(
|
||||
'oidc_permission_grants',
|
||||
{
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => oidcProviders.id, { onDelete: 'cascade' }),
|
||||
permissionName: varchar('permission_name', { length: 100 }).notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.providerId, t.permissionName] })],
|
||||
);
|
||||
|
||||
export const oidcStates = pgTable(
|
||||
'oidc_states',
|
||||
{
|
||||
state: text('state').primaryKey(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => oidcProviders.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
meta: text('meta'),
|
||||
},
|
||||
@@ -29,6 +92,7 @@ export const oidcSessions = pgTable(
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerId: integer('provider_id').references(() => oidcProviders.id, { onDelete: 'set null' }),
|
||||
oidcSubject: text('oidc_subject').notNull(),
|
||||
oidcIssuer: text('oidc_issuer').notNull(),
|
||||
oidcSessionId: text('oidc_session_id'),
|
||||
@@ -45,12 +109,25 @@ export const oidcSessions = pgTable(
|
||||
index('oidc_sessions_subject_issuer_idx').on(t.oidcSubject, t.oidcIssuer),
|
||||
index('oidc_sessions_sid_idx').on(t.oidcSessionId),
|
||||
index('oidc_sessions_expires_at_idx').on(t.expiresAt),
|
||||
index('oidc_sessions_provider_id_idx').on(t.providerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const oidcGroupMappings = pgTable('oidc_group_mappings', {
|
||||
id: serial('id').primaryKey(),
|
||||
oidcGroupClaim: text('oidc_group_claim').notNull().unique(),
|
||||
permissionName: varchar('permission_name', { length: 100 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
export const oidcGroupMappings = pgTable(
|
||||
'oidc_group_mappings',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
providerId: integer('provider_id')
|
||||
.notNull()
|
||||
.references(() => oidcProviders.id, { onDelete: 'cascade' }),
|
||||
oidcGroupClaim: text('oidc_group_claim').notNull(),
|
||||
permissionName: varchar('permission_name', { length: 100 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('oidc_group_mappings_provider_claim_uidx').on(t.providerId, t.oidcGroupClaim)],
|
||||
);
|
||||
|
||||
export type OidcProvider = typeof oidcProviders.$inferSelect;
|
||||
export type NewOidcProvider = typeof oidcProviders.$inferInsert;
|
||||
export type OidcIdentity = typeof oidcIdentities.$inferSelect;
|
||||
export type OidcGroupMapping = typeof oidcGroupMappings.$inferSelect;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { AppSettingsController } from './app-settings.controller';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
|
||||
function makeService(): jest.Mocked<AppSettingsService> {
|
||||
return {
|
||||
@@ -13,9 +12,6 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
||||
isBookBucketAutoFetchEnabled: vi.fn(),
|
||||
getAuthorsAutoEnrichmentWriteMode: vi.fn(),
|
||||
isAuthorsProviderAudnexusEnabled: vi.fn(),
|
||||
getOidcConfig: vi.fn(),
|
||||
updateOidcConfig: vi.fn(),
|
||||
testOidcConnection: vi.fn(),
|
||||
getUploadPattern: vi.fn(),
|
||||
setUploadPattern: vi.fn(),
|
||||
getDownloadPattern: vi.fn(),
|
||||
@@ -26,26 +22,38 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
||||
} as unknown as jest.Mocked<AppSettingsService>;
|
||||
}
|
||||
|
||||
function makeGroupMappingService(): jest.Mocked<OidcGroupMappingAdminService> {
|
||||
function makeOidcProviderService(): jest.Mocked<OidcProviderService> {
|
||||
return {
|
||||
listMappings: vi.fn().mockResolvedValue([]),
|
||||
createMapping: vi.fn(),
|
||||
updateMapping: vi.fn(),
|
||||
deleteMapping: vi.fn(),
|
||||
} as unknown as jest.Mocked<OidcGroupMappingAdminService>;
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
findEnabled: vi.fn().mockResolvedValue([]),
|
||||
findBySlugOrFail: vi.fn(),
|
||||
findByIdOrFail: vi.fn(),
|
||||
findByIssuerUri: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
reorder: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
listGroupMappings: vi.fn().mockResolvedValue([]),
|
||||
createGroupMapping: vi.fn(),
|
||||
updateGroupMapping: vi.fn(),
|
||||
deleteGroupMapping: vi.fn(),
|
||||
} as unknown as jest.Mocked<OidcProviderService>;
|
||||
}
|
||||
|
||||
describe('AppSettingsController', () => {
|
||||
let controller: AppSettingsController;
|
||||
let service: ReturnType<typeof makeService>;
|
||||
let oidcProviderService: ReturnType<typeof makeOidcProviderService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = makeService();
|
||||
oidcProviderService = makeOidcProviderService();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [
|
||||
{ provide: AppSettingsService, useValue: service },
|
||||
{ provide: OidcGroupMappingAdminService, useValue: makeGroupMappingService() },
|
||||
{ provide: OidcProviderService, useValue: oidcProviderService },
|
||||
],
|
||||
}).compile();
|
||||
controller = module.get(AppSettingsController);
|
||||
@@ -97,75 +105,51 @@ describe('AppSettingsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOidcPublicConfig', () => {
|
||||
it('returns only public fields, not clientSecret', async () => {
|
||||
service.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
providerName: 'Keycloak',
|
||||
issuerUri: 'https://kc.example.com',
|
||||
clientId: 'projectx',
|
||||
clientSecret: 'supersecret',
|
||||
scopes: 'openid',
|
||||
iconUrl: 'https://kc.example.com/icon.png',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
});
|
||||
const result = await controller.getOidcPublicConfig();
|
||||
expect(result).not.toHaveProperty('clientSecret');
|
||||
expect(result).not.toHaveProperty('claimMapping');
|
||||
expect(result).not.toHaveProperty('autoProvision');
|
||||
expect(result.clientId).toBe('projectx');
|
||||
expect(result.iconUrl).toBe('https://kc.example.com/icon.png');
|
||||
describe('OIDC provider CRUD', () => {
|
||||
it('getOidcProvidersPublic returns enabled providers without secrets', async () => {
|
||||
oidcProviderService.findEnabled.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
slug: 'keycloak',
|
||||
displayName: 'Keycloak',
|
||||
enabled: true,
|
||||
issuerUri: 'https://kc.example.com',
|
||||
clientId: 'projectx',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid',
|
||||
iconUrl: null,
|
||||
},
|
||||
] as never);
|
||||
const result = await controller.getOidcProvidersPublic();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).not.toHaveProperty('clientSecret');
|
||||
expect(result[0].clientId).toBe('projectx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOidcConfig', () => {
|
||||
it('masks clientSecret when present', async () => {
|
||||
service.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
providerName: '',
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: 'secret123',
|
||||
scopes: 'openid',
|
||||
claimMapping: { username: '', name: '', email: '', groups: '' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
});
|
||||
const result = await controller.getOidcConfig();
|
||||
it('getOidcProvider masks clientSecret', async () => {
|
||||
oidcProviderService.findBySlugOrFail.mockResolvedValue({ id: 1, slug: 'keycloak', clientSecret: 'secret123' } as never);
|
||||
const result = await controller.getOidcProvider('keycloak');
|
||||
expect(result.clientSecret).toBe('***');
|
||||
});
|
||||
|
||||
it('returns empty string for clientSecret when not set', async () => {
|
||||
service.getOidcConfig.mockResolvedValue({
|
||||
enabled: false,
|
||||
providerName: '',
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
scopes: 'openid',
|
||||
claimMapping: { username: '', name: '', email: '', groups: '' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
});
|
||||
const result = await controller.getOidcConfig();
|
||||
it('getOidcProvider returns empty clientSecret when not set', async () => {
|
||||
oidcProviderService.findBySlugOrFail.mockResolvedValue({ id: 1, slug: 'keycloak', clientSecret: '' } as never);
|
||||
const result = await controller.getOidcProvider('keycloak');
|
||||
expect(result.clientSecret).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testOidcConnection', () => {
|
||||
it('delegates to service.testOidcConnection with provided issuerUri', async () => {
|
||||
service.testOidcConnection.mockResolvedValue({
|
||||
success: true,
|
||||
issuer: 'https://kc.example.com',
|
||||
authorizationEndpoint: 'https://kc.example.com/auth',
|
||||
});
|
||||
const result = await controller.testOidcConnection('https://kc.example.com');
|
||||
expect(service.testOidcConnection).toHaveBeenCalledWith('https://kc.example.com');
|
||||
it('testOidcProviderConnection delegates to service', async () => {
|
||||
oidcProviderService.testConnection.mockResolvedValue({ success: true, issuer: 'https://kc.example.com' } as never);
|
||||
const result = await controller.testOidcProviderConnection('keycloak', 'https://kc.example.com');
|
||||
expect(oidcProviderService.testConnection).toHaveBeenCalledWith('https://kc.example.com');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates BadRequestException from service', async () => {
|
||||
service.testOidcConnection.mockRejectedValue(new BadRequestException('Issuer URI is not configured'));
|
||||
await expect(controller.testOidcConnection()).rejects.toThrow(BadRequestException);
|
||||
it('testOidcProviderConnection falls back to saved issuerUri', async () => {
|
||||
oidcProviderService.findBySlugOrFail.mockResolvedValue({ issuerUri: 'https://saved.example.com' } as never);
|
||||
oidcProviderService.testConnection.mockResolvedValue({ success: true } as never);
|
||||
await controller.testOidcProviderConnection('keycloak');
|
||||
expect(oidcProviderService.testConnection).toHaveBeenCalledWith('https://saved.example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Public } from '../../common/decorators/public.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { Auditable } from '../../common/decorators/auditable.decorator';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
||||
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
|
||||
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
|
||||
import { CreateOidcProviderDto } from './dto/create-oidc-provider.dto';
|
||||
import { UpdateOidcProviderDto } from './dto/update-oidc-provider.dto';
|
||||
import { CreateGroupMappingDto } from './dto/create-group-mapping.dto';
|
||||
import { UpdateGroupMappingDto } from './dto/update-group-mapping.dto';
|
||||
|
||||
@@ -17,7 +18,7 @@ import { UpdateGroupMappingDto } from './dto/update-group-mapping.dto';
|
||||
export class AppSettingsController {
|
||||
constructor(
|
||||
private readonly appSettingsService: AppSettingsService,
|
||||
private readonly groupMappingAdmin: OidcGroupMappingAdminService,
|
||||
private readonly oidcProviderService: OidcProviderService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -62,58 +63,104 @@ export class AppSettingsController {
|
||||
return { pattern: dto.pattern };
|
||||
}
|
||||
|
||||
// --- OIDC Provider CRUD ---
|
||||
|
||||
@Public()
|
||||
@Get('oidc/public')
|
||||
async getOidcPublicConfig() {
|
||||
const config = await this.appSettingsService.getOidcConfig();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
providerName: config.providerName,
|
||||
issuerUri: config.issuerUri,
|
||||
clientId: config.clientId,
|
||||
scopes: config.scopes,
|
||||
iconUrl: config.iconUrl,
|
||||
};
|
||||
@Get('oidc/providers/public')
|
||||
async getOidcProvidersPublic() {
|
||||
const providers = await this.oidcProviderService.findEnabled();
|
||||
return providers.map((p) => ({
|
||||
slug: p.slug,
|
||||
displayName: p.displayName,
|
||||
enabled: p.enabled,
|
||||
iconUrl: p.iconUrl,
|
||||
clientId: p.clientId,
|
||||
scopes: p.scopes,
|
||||
}));
|
||||
}
|
||||
|
||||
@Get('oidc')
|
||||
async getOidcConfig() {
|
||||
const config = await this.appSettingsService.getOidcConfig();
|
||||
return { ...config, clientSecret: config.clientSecret ? '***' : '' };
|
||||
@Get('oidc/providers')
|
||||
listOidcProviders() {
|
||||
return this.oidcProviderService.findAll();
|
||||
}
|
||||
|
||||
@Put('oidc')
|
||||
@Auditable({ action: AuditAction.AppSettingsUpdate, resource: AuditResource.AppSettings, description: 'Updated OIDC configuration' })
|
||||
updateOidcConfig(@Body() dto: UpdateOidcConfigDto) {
|
||||
return this.appSettingsService.updateOidcConfig(dto);
|
||||
@Get('oidc/providers/:slug')
|
||||
async getOidcProvider(@Param('slug') slug: string) {
|
||||
const provider = await this.oidcProviderService.findBySlugOrFail(slug);
|
||||
return { ...provider, clientSecret: provider.clientSecret ? '***' : '' };
|
||||
}
|
||||
|
||||
@Post('oidc/test')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
testOidcConnection(@Query('issuerUri') issuerUri?: string) {
|
||||
return this.appSettingsService.testOidcConnection(issuerUri);
|
||||
}
|
||||
|
||||
@Get('oidc/group-mappings')
|
||||
listGroupMappings() {
|
||||
return this.groupMappingAdmin.listMappings();
|
||||
}
|
||||
|
||||
@Post('oidc/group-mappings')
|
||||
@Post('oidc/providers')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createGroupMapping(@Body() dto: CreateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.createMapping(dto.oidcGroupClaim, dto.permissionName);
|
||||
@Auditable({
|
||||
action: AuditAction.AppSettingsUpdate,
|
||||
resource: AuditResource.AppSettings,
|
||||
description: (req) => {
|
||||
const slug = (req.body as Record<string, unknown> | undefined)?.slug;
|
||||
return `Created OIDC provider '${typeof slug === 'string' ? slug : 'unknown'}'`;
|
||||
},
|
||||
})
|
||||
createOidcProvider(@Body() dto: CreateOidcProviderDto) {
|
||||
return this.oidcProviderService.create(dto);
|
||||
}
|
||||
|
||||
@Put('oidc/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateGroupMapping(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.updateMapping(id, dto.permissionName);
|
||||
@Put('oidc/providers/:slug')
|
||||
@Auditable({
|
||||
action: AuditAction.AppSettingsUpdate,
|
||||
resource: AuditResource.AppSettings,
|
||||
description: (req) => `Updated OIDC provider '${req.params['slug']}'`,
|
||||
})
|
||||
updateOidcProvider(@Param('slug') slug: string, @Body() dto: UpdateOidcProviderDto) {
|
||||
return this.oidcProviderService.update(slug, dto);
|
||||
}
|
||||
|
||||
@Delete('oidc/group-mappings/:id')
|
||||
@Delete('oidc/providers/:slug')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteGroupMapping(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.groupMappingAdmin.deleteMapping(id);
|
||||
@Auditable({
|
||||
action: AuditAction.AppSettingsUpdate,
|
||||
resource: AuditResource.AppSettings,
|
||||
description: (req) => `Deleted OIDC provider '${req.params['slug']}'`,
|
||||
})
|
||||
deleteOidcProvider(@Param('slug') slug: string) {
|
||||
return this.oidcProviderService.remove(slug);
|
||||
}
|
||||
|
||||
@Post('oidc/providers/:slug/test')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async testOidcProviderConnection(@Param('slug') slug: string, @Query('issuerUri') issuerUri?: string) {
|
||||
const uri = issuerUri || (await this.oidcProviderService.findBySlugOrFail(slug)).issuerUri;
|
||||
return this.oidcProviderService.testConnection(uri);
|
||||
}
|
||||
|
||||
@Put('oidc/providers/reorder')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Auditable({ action: AuditAction.AppSettingsUpdate, resource: AuditResource.AppSettings, description: 'Reordered OIDC providers' })
|
||||
reorderOidcProviders(@Body() body: { slugs: string[] }) {
|
||||
return this.oidcProviderService.reorder(body.slugs);
|
||||
}
|
||||
|
||||
// --- Provider-scoped group mappings ---
|
||||
|
||||
@Get('oidc/providers/:slug/group-mappings')
|
||||
listProviderGroupMappings(@Param('slug') slug: string) {
|
||||
return this.oidcProviderService.listGroupMappings(slug);
|
||||
}
|
||||
|
||||
@Post('oidc/providers/:slug/group-mappings')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createProviderGroupMapping(@Param('slug') slug: string, @Body() dto: CreateGroupMappingDto) {
|
||||
return this.oidcProviderService.createGroupMapping(slug, dto.oidcGroupClaim, dto.permissionName);
|
||||
}
|
||||
|
||||
@Put('oidc/providers/:slug/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateProviderGroupMapping(@Param('slug') slug: string, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupMappingDto) {
|
||||
return this.oidcProviderService.updateGroupMapping(slug, id, dto.permissionName);
|
||||
}
|
||||
|
||||
@Delete('oidc/providers/:slug/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteProviderGroupMapping(@Param('slug') slug: string, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.oidcProviderService.deleteGroupMapping(slug, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { AppSettingsController } from './app-settings.controller';
|
||||
import { AppSettingsRepository } from './app-settings.repository';
|
||||
import { AppSettingsService } from './app-settings.service';
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
import { OidcProviderRepository } from './oidc-provider.repository';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [AppSettingsRepository, AppSettingsService, OidcGroupMappingAdminService],
|
||||
exports: [AppSettingsService],
|
||||
providers: [AppSettingsRepository, AppSettingsService, OidcGroupMappingAdminService, OidcProviderRepository, OidcProviderService],
|
||||
exports: [AppSettingsService, OidcProviderService],
|
||||
})
|
||||
export class AppSettingsModule {}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, Matches, MaxLength, MinLength, ValidateNested } from 'class-validator';
|
||||
|
||||
class ClaimMappingDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
groups: string;
|
||||
}
|
||||
|
||||
class AutoProvisionDto {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
allowLocalLinking: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(100, { each: true })
|
||||
defaultPermissionNames: string[] = [];
|
||||
}
|
||||
|
||||
export class CreateOidcProviderDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@Matches(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, {
|
||||
message: 'Slug must be lowercase alphanumeric with hyphens, starting and ending with a letter or number',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
displayName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
issuerUri: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(256)
|
||||
clientId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
clientSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
scopes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClaimMappingDto)
|
||||
claimMapping?: ClaimMappingDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => AutoProvisionDto)
|
||||
autoProvision?: AutoProvisionDto;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateNested } from 'class-validator';
|
||||
|
||||
class ClaimMappingDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
groups: string;
|
||||
}
|
||||
|
||||
class AutoProvisionDto {
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
allowLocalLinking: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(100, { each: true })
|
||||
defaultPermissionNames: string[] = [];
|
||||
}
|
||||
|
||||
export class UpdateOidcProviderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
displayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
issuerUri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
clientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
clientSecret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
scopes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClaimMappingDto)
|
||||
claimMapping?: ClaimMappingDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => AutoProvisionDto)
|
||||
autoProvision?: AutoProvisionDto;
|
||||
}
|
||||
@@ -36,17 +36,6 @@ describe('OidcGroupMappingAdminService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMapping', () => {
|
||||
it('inserts and returns the new row', async () => {
|
||||
const row = { id: 1, oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
const result = await service.createMapping('editors', 'library_edit_metadata');
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
expect(db.values).toHaveBeenCalledWith({ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' });
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMapping', () => {
|
||||
it('updates permissionName and returns the updated row', async () => {
|
||||
const row = { id: 5, oidcGroupClaim: 'editors', permissionName: 'manage_users' };
|
||||
|
||||
@@ -7,6 +7,10 @@ import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
/**
|
||||
* @deprecated Use OidcProviderService for provider-scoped group mapping operations.
|
||||
* Kept temporarily for backward compatibility during migration.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OidcGroupMappingAdminService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
@@ -15,11 +19,6 @@ export class OidcGroupMappingAdminService {
|
||||
return this.db.query.oidcGroupMappings.findMany({ orderBy: (t, { asc }) => [asc(t.oidcGroupClaim)] });
|
||||
}
|
||||
|
||||
async createMapping(oidcGroupClaim: string, permissionName: string) {
|
||||
const [row] = await this.db.insert(schema.oidcGroupMappings).values({ oidcGroupClaim, permissionName }).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateMapping(id: number, permissionName: string) {
|
||||
const [row] = await this.db.update(schema.oidcGroupMappings).set({ permissionName }).where(eq(schema.oidcGroupMappings.id, id)).returning();
|
||||
if (!row) throw new NotFoundException(`Group mapping ${id} not found`);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcProviderRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
findAll() {
|
||||
return this.db.query.oidcProviders.findMany({
|
||||
orderBy: [asc(schema.oidcProviders.displayOrder), asc(schema.oidcProviders.id)],
|
||||
});
|
||||
}
|
||||
|
||||
findEnabled() {
|
||||
return this.db.query.oidcProviders.findMany({
|
||||
where: eq(schema.oidcProviders.enabled, true),
|
||||
orderBy: [asc(schema.oidcProviders.displayOrder), asc(schema.oidcProviders.id)],
|
||||
});
|
||||
}
|
||||
|
||||
findBySlug(slug: string) {
|
||||
return this.db.query.oidcProviders.findFirst({
|
||||
where: eq(schema.oidcProviders.slug, slug),
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: number) {
|
||||
return this.db.query.oidcProviders.findFirst({
|
||||
where: eq(schema.oidcProviders.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
findByIssuerUri(issuerUri: string) {
|
||||
return this.db.query.oidcProviders.findFirst({
|
||||
where: eq(schema.oidcProviders.issuerUri, issuerUri),
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: typeof schema.oidcProviders.$inferInsert) {
|
||||
const [row] = await this.db.insert(schema.oidcProviders).values(data).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Omit<typeof schema.oidcProviders.$inferInsert, 'id'>>) {
|
||||
const [row] = await this.db.update(schema.oidcProviders).set(data).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const [row] = await this.db.delete(schema.oidcProviders).where(eq(schema.oidcProviders.id, id)).returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async reorder(orderedIds: number[]) {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await tx.update(schema.oidcProviders).set({ displayOrder: i }).where(eq(schema.oidcProviders.id, orderedIds[i]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async countLinkedIdentities(providerId: number): Promise<number> {
|
||||
const [result] = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.oidcIdentities)
|
||||
.where(eq(schema.oidcIdentities.providerId, providerId));
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
findGroupMappingsByProvider(providerId: number) {
|
||||
return this.db.query.oidcGroupMappings.findMany({
|
||||
where: eq(schema.oidcGroupMappings.providerId, providerId),
|
||||
orderBy: [asc(schema.oidcGroupMappings.oidcGroupClaim)],
|
||||
});
|
||||
}
|
||||
|
||||
async createGroupMapping(providerId: number, oidcGroupClaim: string, permissionName: string) {
|
||||
const [row] = await this.db.insert(schema.oidcGroupMappings).values({ providerId, oidcGroupClaim, permissionName }).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async updateGroupMapping(id: number, providerId: number, permissionName: string) {
|
||||
const [row] = await this.db
|
||||
.update(schema.oidcGroupMappings)
|
||||
.set({ permissionName })
|
||||
.where(and(eq(schema.oidcGroupMappings.id, id), eq(schema.oidcGroupMappings.providerId, providerId)))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async deleteGroupMapping(id: number, providerId: number) {
|
||||
const [row] = await this.db
|
||||
.delete(schema.oidcGroupMappings)
|
||||
.where(and(eq(schema.oidcGroupMappings.id, id), eq(schema.oidcGroupMappings.providerId, providerId)))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
vi.mock('../../common/utils/ssrf.utils', () => ({
|
||||
ensureSafeUrl: vi.fn().mockImplementation((url: string) => Promise.resolve(new URL(url.replace(/\/$/, '')))),
|
||||
}));
|
||||
|
||||
import { OidcProviderRepository } from './oidc-provider.repository';
|
||||
import { OidcProviderService } from './oidc-provider.service';
|
||||
|
||||
function makeRepo(): jest.Mocked<OidcProviderRepository> {
|
||||
return {
|
||||
findAll: vi.fn().mockResolvedValue([]),
|
||||
findEnabled: vi.fn().mockResolvedValue([]),
|
||||
findBySlug: vi.fn().mockResolvedValue(null),
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByIssuerUri: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 1, slug: 'keycloak' }),
|
||||
update: vi.fn().mockResolvedValue({ id: 1, slug: 'keycloak' }),
|
||||
remove: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
countLinkedIdentities: vi.fn().mockResolvedValue(0),
|
||||
reorder: vi.fn().mockResolvedValue(undefined),
|
||||
findGroupMappingsByProvider: vi.fn().mockResolvedValue([]),
|
||||
createGroupMapping: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
updateGroupMapping: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
deleteGroupMapping: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
} as unknown as jest.Mocked<OidcProviderRepository>;
|
||||
}
|
||||
|
||||
function makeConfig(nodeEnv = 'development'): ConfigService {
|
||||
return { get: vi.fn().mockReturnValue(nodeEnv) } as unknown as ConfigService;
|
||||
}
|
||||
|
||||
describe('OidcProviderService', () => {
|
||||
let service: OidcProviderService;
|
||||
let repo: ReturnType<typeof makeRepo>;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = makeRepo();
|
||||
service = new OidcProviderService(repo, makeConfig());
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('findBySlugOrFail', () => {
|
||||
it('returns provider when found', async () => {
|
||||
const provider = { id: 1, slug: 'keycloak' };
|
||||
repo.findBySlug.mockResolvedValue(provider as never);
|
||||
await expect(service.findBySlugOrFail('keycloak')).resolves.toEqual(provider);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when not found', async () => {
|
||||
repo.findBySlug.mockResolvedValue(null as never);
|
||||
await expect(service.findBySlugOrFail('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrFail', () => {
|
||||
it('returns provider when found', async () => {
|
||||
const provider = { id: 1, slug: 'keycloak' };
|
||||
repo.findById.mockResolvedValue(provider as never);
|
||||
await expect(service.findByIdOrFail(1)).resolves.toEqual(provider);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when not found', async () => {
|
||||
repo.findById.mockResolvedValue(null as never);
|
||||
await expect(service.findByIdOrFail(999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a provider with valid slug', async () => {
|
||||
await service.create({ slug: 'keycloak', displayName: 'Keycloak', issuerUri: 'https://kc.example.com', clientId: 'client' });
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects slug that is too short', async () => {
|
||||
await expect(service.create({ slug: 'a', displayName: 'X', issuerUri: 'https://x.com', clientId: 'c' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects slug with uppercase characters', async () => {
|
||||
await expect(service.create({ slug: 'MyProvider', displayName: 'X', issuerUri: 'https://x.com', clientId: 'c' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects slug that starts with a hyphen', async () => {
|
||||
await expect(service.create({ slug: '-keycloak', displayName: 'X', issuerUri: 'https://x.com', clientId: 'c' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate slug', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
await expect(service.create({ slug: 'keycloak', displayName: 'Keycloak', issuerUri: 'https://kc.example.com', clientId: 'c' })).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates an existing provider', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
await service.update('keycloak', { displayName: 'Keycloak Updated' });
|
||||
expect(repo.update).toHaveBeenCalledWith(1, { displayName: 'Keycloak Updated' });
|
||||
});
|
||||
|
||||
it('throws when provider not found', async () => {
|
||||
repo.findBySlug.mockResolvedValue(null as never);
|
||||
await expect(service.update('missing', { displayName: 'X' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes a provider with no linked identities', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
repo.countLinkedIdentities.mockResolvedValue(0);
|
||||
await service.remove('keycloak');
|
||||
expect(repo.remove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('rejects deletion when identities are linked', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
repo.countLinkedIdentities.mockResolvedValue(5);
|
||||
await expect(service.remove('keycloak')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws when provider not found', async () => {
|
||||
repo.findBySlug.mockResolvedValue(null as never);
|
||||
await expect(service.remove('missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
it('reorders providers by slug', async () => {
|
||||
repo.findAll.mockResolvedValue([
|
||||
{ id: 1, slug: 'keycloak' },
|
||||
{ id: 2, slug: 'authentik' },
|
||||
] as never);
|
||||
await service.reorder(['authentik', 'keycloak']);
|
||||
expect(repo.reorder).toHaveBeenCalledWith([2, 1]);
|
||||
});
|
||||
|
||||
it('throws for unknown slug', async () => {
|
||||
repo.findAll.mockResolvedValue([{ id: 1, slug: 'keycloak' }] as never);
|
||||
await expect(service.reorder(['unknown'])).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group mappings', () => {
|
||||
it('listGroupMappings delegates to repo', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
await service.listGroupMappings('keycloak');
|
||||
expect(repo.findGroupMappingsByProvider).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('createGroupMapping delegates to repo', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
await service.createGroupMapping('keycloak', 'admins', 'manage_users');
|
||||
expect(repo.createGroupMapping).toHaveBeenCalledWith(1, 'admins', 'manage_users');
|
||||
});
|
||||
|
||||
it('deleteGroupMapping throws when not found', async () => {
|
||||
repo.findBySlug.mockResolvedValue({ id: 1, slug: 'keycloak' } as never);
|
||||
repo.deleteGroupMapping.mockResolvedValue(null as never);
|
||||
await expect(service.deleteGroupMapping('keycloak', 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { OidcAutoProvision, OidcClaimMapping } from '@projectx/types';
|
||||
|
||||
import { ensureSafeUrl } 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]$/;
|
||||
|
||||
@Injectable()
|
||||
export class OidcProviderService {
|
||||
private readonly logger = new Logger(OidcProviderService.name);
|
||||
|
||||
constructor(
|
||||
private readonly repo: OidcProviderRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
findAll() {
|
||||
return this.repo.findAll();
|
||||
}
|
||||
|
||||
findEnabled() {
|
||||
return this.repo.findEnabled();
|
||||
}
|
||||
|
||||
async findBySlugOrFail(slug: string) {
|
||||
const provider = await this.repo.findBySlug(slug);
|
||||
if (!provider) throw new NotFoundException(`OIDC provider '${slug}' not found`);
|
||||
return provider;
|
||||
}
|
||||
|
||||
async findByIdOrFail(id: number) {
|
||||
const provider = await this.repo.findById(id);
|
||||
if (!provider) throw new NotFoundException(`OIDC provider #${id} not found`);
|
||||
return provider;
|
||||
}
|
||||
|
||||
findByIssuerUri(issuerUri: string) {
|
||||
return this.repo.findByIssuerUri(issuerUri);
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
iconUrl?: string;
|
||||
claimMapping?: OidcClaimMapping;
|
||||
autoProvision?: OidcAutoProvision;
|
||||
}) {
|
||||
this.validateSlug(data.slug);
|
||||
|
||||
const existing = await this.repo.findBySlug(data.slug);
|
||||
if (existing) throw new ConflictException(`Provider slug '${data.slug}' already exists`);
|
||||
|
||||
return this.repo.create(data);
|
||||
}
|
||||
|
||||
async update(
|
||||
slug: string,
|
||||
data: {
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
issuerUri?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string | null;
|
||||
scopes?: string;
|
||||
iconUrl?: string | null;
|
||||
claimMapping?: OidcClaimMapping;
|
||||
autoProvision?: OidcAutoProvision;
|
||||
},
|
||||
) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
const updated = await this.repo.update(provider.id, data);
|
||||
if (!updated) throw new NotFoundException(`OIDC provider '${slug}' not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(slug: string) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
const identityCount = await this.repo.countLinkedIdentities(provider.id);
|
||||
if (identityCount > 0) {
|
||||
throw new BadRequestException(`Cannot delete provider '${slug}': ${identityCount} user(s) have linked identities. Unlink all users first.`);
|
||||
}
|
||||
const removed = await this.repo.remove(provider.id);
|
||||
if (!removed) throw new NotFoundException(`OIDC provider '${slug}' not found`);
|
||||
}
|
||||
|
||||
async reorder(orderedSlugs: string[]) {
|
||||
const providers = await this.repo.findAll();
|
||||
const slugToId = new Map(providers.map((p) => [p.slug, p.id]));
|
||||
const orderedIds: number[] = [];
|
||||
for (const slug of orderedSlugs) {
|
||||
const id = slugToId.get(slug);
|
||||
if (id === undefined) throw new BadRequestException(`Unknown provider slug: '${slug}'`);
|
||||
orderedIds.push(id);
|
||||
}
|
||||
await this.repo.reorder(orderedIds);
|
||||
}
|
||||
|
||||
async testConnection(issuerUri: string) {
|
||||
if (!issuerUri) throw new BadRequestException('Issuer URI is required');
|
||||
|
||||
const parsed = await ensureSafeUrl(issuerUri, { allowLocal: this.config.get<string>('app.nodeEnv') !== 'production' });
|
||||
const normalized = parsed.href.replace(/\/$/, '');
|
||||
const discoveryUrl = `${normalized}/.well-known/openid-configuration`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OIDC_TEST_TIMEOUT_MS);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(discoveryUrl, { signal: controller.signal, redirect: 'manual' });
|
||||
if (!res.ok) throw new BadRequestException(`Provider returned HTTP ${res.status}`);
|
||||
|
||||
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`);
|
||||
return {
|
||||
success: true,
|
||||
issuer: json.issuer,
|
||||
authorizationEndpoint: json.authorization_endpoint,
|
||||
tokenEndpoint: (json as Record<string, unknown>).token_endpoint as string | undefined,
|
||||
userinfoEndpoint: (json as Record<string, unknown>).userinfo_endpoint as string | undefined,
|
||||
jwksUri: (json as Record<string, unknown>).jwks_uri as string | undefined,
|
||||
supportedScopes: (json as Record<string, unknown>).scopes_supported as string[] | undefined,
|
||||
supportedGrantTypes: (json as Record<string, unknown>).grant_types_supported as string[] | undefined,
|
||||
codeChallengeMethodsSupported: (json as Record<string, unknown>).code_challenge_methods_supported as string[] | undefined,
|
||||
backchannelLogoutSupported: (json as Record<string, unknown>).backchannel_logout_supported as boolean | undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - start;
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
|
||||
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`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
this.logger.warn(
|
||||
`[oidc_provider.test_connection] [fail] issuerUri=${issuerUri} durationMs=${durationMs} errorClass=${errorClass} error="${message}" - test failed`,
|
||||
);
|
||||
throw new BadRequestException(`OIDC connection test failed: ${message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Group mapping operations scoped to a provider
|
||||
async listGroupMappings(slug: string) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
return this.repo.findGroupMappingsByProvider(provider.id);
|
||||
}
|
||||
|
||||
async createGroupMapping(slug: string, oidcGroupClaim: string, permissionName: string) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
return this.repo.createGroupMapping(provider.id, oidcGroupClaim, permissionName);
|
||||
}
|
||||
|
||||
async updateGroupMapping(slug: string, mappingId: number, permissionName: string) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
const updated = await this.repo.updateGroupMapping(mappingId, provider.id, permissionName);
|
||||
if (!updated) throw new NotFoundException(`Group mapping #${mappingId} not found for provider '${slug}'`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteGroupMapping(slug: string, mappingId: number) {
|
||||
const provider = await this.findBySlugOrFail(slug);
|
||||
const deleted = await this.repo.deleteGroupMapping(mappingId, provider.id);
|
||||
if (!deleted) throw new NotFoundException(`Group mapping #${mappingId} not found for provider '${slug}'`);
|
||||
}
|
||||
|
||||
private validateSlug(slug: string) {
|
||||
if (slug.length < 2 || slug.length > 100) {
|
||||
throw new BadRequestException('Slug must be between 2 and 100 characters');
|
||||
}
|
||||
if (!SLUG_PATTERN.test(slug)) {
|
||||
throw new BadRequestException('Slug must be lowercase alphanumeric with hyphens, and must start/end with a letter or number');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOidcDiscoveryDoc(val: unknown): val is { issuer: string; authorization_endpoint: string } {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
typeof (val as Record<string, unknown>)['issuer'] === 'string' &&
|
||||
typeof (val as Record<string, unknown>)['authorization_endpoint'] === 'string'
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function makeController() {
|
||||
generatePreviewState: vi.fn(),
|
||||
handleCallback: vi.fn(),
|
||||
handleBackchannelLogout: vi.fn(),
|
||||
getLinkedIdentity: vi.fn(),
|
||||
getLinkedIdentities: vi.fn(),
|
||||
unlinkIdentity: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -71,24 +71,25 @@ describe('AuthController', () => {
|
||||
oidcService.generateState.mockResolvedValue(stateResult);
|
||||
oidcService.generateLinkState.mockResolvedValue(stateResult);
|
||||
oidcService.generatePreviewState.mockResolvedValue(stateResult);
|
||||
oidcService.getLinkedIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://idp.example.com' });
|
||||
oidcService.getLinkedIdentities.mockResolvedValue([]);
|
||||
|
||||
await expect(controller.oidcGenerateState()).resolves.toEqual(stateResult);
|
||||
await expect(controller.oidcGenerateState('keycloak')).resolves.toEqual(stateResult);
|
||||
await controller.oidcCallback({ code: 'abc' } as never, reply);
|
||||
await controller.oidcBackchannelLogout({ body: { logout_token: 'logout-1' } } as never);
|
||||
await controller.oidcBackchannelLogout({ body: undefined } as never);
|
||||
await controller.oidcGenerateLinkState({ id: 7 } as never);
|
||||
await controller.oidcGeneratePreviewState();
|
||||
await controller.oidcGetIdentity({ id: 7 } as never);
|
||||
await controller.oidcUnlinkIdentity({ id: 7 } as never, { password: 'Secret1!' });
|
||||
await controller.oidcGenerateLinkState({ id: 7 } as never, 'keycloak');
|
||||
await controller.oidcGeneratePreviewState('keycloak');
|
||||
await controller.oidcGetIdentities({ id: 7 } as never);
|
||||
await controller.oidcUnlinkIdentity({ id: 7 } as never, 3, { password: 'Secret1!' });
|
||||
|
||||
expect(oidcService.generateState).toHaveBeenCalledWith('keycloak');
|
||||
expect(oidcService.handleCallback).toHaveBeenCalledWith({ code: 'abc' }, reply);
|
||||
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(1, 'logout-1');
|
||||
expect(oidcService.handleBackchannelLogout).toHaveBeenNthCalledWith(2, '');
|
||||
expect(oidcService.generateLinkState).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.generatePreviewState).toHaveBeenCalled();
|
||||
expect(oidcService.getLinkedIdentity).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 'Secret1!');
|
||||
expect(oidcService.generateLinkState).toHaveBeenCalledWith(7, 'keycloak');
|
||||
expect(oidcService.generatePreviewState).toHaveBeenCalledWith('keycloak');
|
||||
expect(oidcService.getLinkedIdentities).toHaveBeenCalledWith(7);
|
||||
expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 3, 'Secret1!');
|
||||
});
|
||||
|
||||
it('defines throttling metadata for sensitive public endpoints', () => {
|
||||
|
||||
@@ -125,10 +125,10 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/state')
|
||||
@Post('oidc/:slug/state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateState() {
|
||||
return this.oidcService.generateState();
|
||||
oidcGenerateState(@Param('slug') slug: string) {
|
||||
return this.oidcService.generateState(slug);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@@ -149,27 +149,27 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/link-state')
|
||||
@Post('oidc/:slug/link-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateLinkState(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.generateLinkState(user.id);
|
||||
oidcGenerateLinkState(@CurrentUser() user: RequestUser, @Param('slug') slug: string) {
|
||||
return this.oidcService.generateLinkState(user.id, slug);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/preview-state')
|
||||
@Post('oidc/:slug/preview-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGeneratePreviewState() {
|
||||
return this.oidcService.generatePreviewState();
|
||||
oidcGeneratePreviewState(@Param('slug') slug: string) {
|
||||
return this.oidcService.generatePreviewState(slug);
|
||||
}
|
||||
|
||||
@Get('oidc/identity')
|
||||
oidcGetIdentity(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.getLinkedIdentity(user.id);
|
||||
@Get('oidc/identities')
|
||||
oidcGetIdentities(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.getLinkedIdentities(user.id);
|
||||
}
|
||||
|
||||
@Delete('oidc/identity')
|
||||
@Delete('oidc/identities/:providerId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Body() dto: OidcUnlinkDto) {
|
||||
return this.oidcService.unlinkIdentity(user.id, dto.password);
|
||||
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Param('providerId', ParseIntPipe) providerId: number, @Body() dto: OidcUnlinkDto) {
|
||||
return this.oidcService.unlinkIdentity(user.id, providerId, dto.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeDb(overrides?: Record<string, unknown>) {
|
||||
refreshTokens: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
users: { findFirst: vi.fn() },
|
||||
passwordResetTokens: { findFirst: vi.fn() },
|
||||
oidcProviders: { findFirst: vi.fn() },
|
||||
},
|
||||
$count: vi.fn().mockResolvedValue(0),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
@@ -104,7 +105,6 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
jwtService as never,
|
||||
config as never,
|
||||
systemMailService as never,
|
||||
appSettings as never,
|
||||
oidcSessionRepo as never,
|
||||
oidcDiscovery as never,
|
||||
{ emit: vi.fn() } as never,
|
||||
@@ -832,11 +832,16 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('returns logout URL when OIDC end-session endpoint is available', async () => {
|
||||
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
const { service, db, userService, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 7 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).oidcProviders.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
});
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
providerId: 1,
|
||||
idTokenHint: 'id-token-hint',
|
||||
});
|
||||
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
|
||||
@@ -859,11 +864,16 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('revokes OIDC session and returns empty object when end-session endpoint is missing', async () => {
|
||||
const { service, db, userService, appSettings, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
const { service, db, userService, oidcSessionRepo, oidcDiscovery } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).refreshTokens.findFirst.mockResolvedValue({ id: 1, userId: 8 });
|
||||
userService.incrementTokenVersion.mockResolvedValue(undefined);
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: true, issuerUri: 'https://issuer.example' });
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).oidcProviders.findFirst.mockResolvedValue({
|
||||
id: 1,
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
});
|
||||
oidcSessionRepo.findActiveByUserId.mockResolvedValue({
|
||||
providerId: 1,
|
||||
idTokenHint: 'id-token-hint',
|
||||
});
|
||||
oidcDiscovery.getDiscoveryDoc.mockResolvedValue({
|
||||
|
||||
@@ -28,7 +28,6 @@ import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { resolveUserAvatarUrl } from '../../common/utils/user-avatar-url';
|
||||
import { SystemMailService } from '../email/system-mail.service';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
@@ -72,7 +71,6 @@ export class AuthService {
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly systemMailService: SystemMailService,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly oidcSessionRepo: OidcSessionRepository,
|
||||
private readonly oidcDiscovery: OidcDiscoveryService,
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
@@ -374,10 +372,11 @@ export class AuthService {
|
||||
|
||||
const oidcLogoutStart = Date.now();
|
||||
try {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) return {};
|
||||
if (!oidcSession.providerId) return {};
|
||||
const provider = await this.db.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, oidcSession.providerId) });
|
||||
if (!provider?.enabled) return {};
|
||||
|
||||
const disc = await this.oidcDiscovery.getDiscoveryDoc(config.issuerUri);
|
||||
const disc = await this.oidcDiscovery.getDiscoveryDoc(provider.issuerUri);
|
||||
if (!disc.endSessionEndpoint) return {};
|
||||
|
||||
const origin = req.headers['origin'] ?? req.headers['referer'];
|
||||
|
||||
@@ -13,6 +13,22 @@ const DISCOVERY_DOC = {
|
||||
backchannelLogoutSupported: true,
|
||||
};
|
||||
|
||||
const PROVIDER = {
|
||||
id: 1,
|
||||
slug: 'keycloak',
|
||||
enabled: true,
|
||||
issuerUri: 'https://idp.example.com',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'secret',
|
||||
};
|
||||
|
||||
function makeLogoutToken(payload: Record<string, unknown> = {}): string {
|
||||
const defaultPayload = { iss: 'https://idp.example.com', sub: 'u1', ...payload };
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
|
||||
const body = Buffer.from(JSON.stringify(defaultPayload)).toString('base64url');
|
||||
return `${header}.${body}.fake-sig`;
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
@@ -33,8 +49,8 @@ function makeDb() {
|
||||
|
||||
function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
const db = { ...makeDb(), ...dbOverrides };
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({ enabled: true, issuerUri: 'https://idp.example.com', clientId: 'client-id' }),
|
||||
const providerService = {
|
||||
findByIssuerUri: vi.fn().mockResolvedValue(PROVIDER),
|
||||
};
|
||||
const discovery = {
|
||||
getDiscoveryDoc: vi.fn().mockResolvedValue(DISCOVERY_DOC),
|
||||
@@ -54,22 +70,30 @@ function makeService(dbOverrides?: Partial<ReturnType<typeof makeDb>>) {
|
||||
|
||||
const service = new BackchannelLogoutService(
|
||||
db as never,
|
||||
appSettings as never,
|
||||
providerService as never,
|
||||
discovery as never,
|
||||
tokenValidator as never,
|
||||
sessionRepo as never,
|
||||
userService as never,
|
||||
);
|
||||
|
||||
return { service, db, appSettings, discovery, tokenValidator, sessionRepo, userService };
|
||||
return { service, db, providerService, discovery, tokenValidator, sessionRepo, userService };
|
||||
}
|
||||
|
||||
describe('BackchannelLogoutService', () => {
|
||||
it('does nothing when OIDC is disabled', async () => {
|
||||
const { service, appSettings, tokenValidator } = makeService();
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
it('does nothing when no matching provider found', async () => {
|
||||
const { service, providerService, tokenValidator } = makeService();
|
||||
providerService.findByIssuerUri.mockResolvedValue(null);
|
||||
|
||||
await service.handleLogout('logout-token');
|
||||
await service.handleLogout(makeLogoutToken());
|
||||
expect(tokenValidator.validateLogoutToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when provider is disabled', async () => {
|
||||
const { service, providerService, tokenValidator } = makeService();
|
||||
providerService.findByIssuerUri.mockResolvedValue({ ...PROVIDER, enabled: false });
|
||||
|
||||
await service.handleLogout(makeLogoutToken());
|
||||
expect(tokenValidator.validateLogoutToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -78,7 +102,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', sid: 'sess-1', jti: 'jti-1' });
|
||||
sessionRepo.findActiveBySid.mockResolvedValue({ userId: 42 });
|
||||
|
||||
await service.handleLogout('logout-token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1' }));
|
||||
|
||||
expect(sessionRepo.revokeBySid).toHaveBeenCalledWith('sess-1');
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(42);
|
||||
@@ -90,7 +114,7 @@ describe('BackchannelLogoutService', () => {
|
||||
sessionRepo.findActiveBySid.mockResolvedValue(null);
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 99 }]);
|
||||
|
||||
await service.handleLogout('logout-token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1' }));
|
||||
|
||||
expect(sessionRepo.revokeBySubjectAndIssuer).toHaveBeenCalledWith('u1', 'https://idp.example.com');
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(99);
|
||||
@@ -101,7 +125,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'jti-3' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 55 }]);
|
||||
|
||||
await service.handleLogout('logout-token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1' }));
|
||||
|
||||
expect(sessionRepo.findActiveBySid).not.toHaveBeenCalled();
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(55);
|
||||
@@ -112,7 +136,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'ghost-user', jti: 'jti-4' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([]);
|
||||
|
||||
await service.handleLogout('logout-token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'ghost-user' }));
|
||||
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -122,7 +146,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 7 }]);
|
||||
|
||||
await service.handleLogout('token-no-jti');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1' }));
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
@@ -146,9 +170,8 @@ describe('BackchannelLogoutService', () => {
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('token-1');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1', jti: 'replay-jti' }));
|
||||
|
||||
// incrementTokenVersion should NOT be called because insert returned empty (replay)
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -161,7 +184,7 @@ describe('BackchannelLogoutService', () => {
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('fresh-token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1', jti: 'fresh-jti' }));
|
||||
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(10);
|
||||
});
|
||||
@@ -180,7 +203,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'jti-exp', exp });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 5 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1', jti: 'jti-exp', exp }));
|
||||
|
||||
const [[{ jti, expiresAt }]] = valuesMock.mock.calls;
|
||||
expect(jti).toBe('jti-exp');
|
||||
@@ -192,7 +215,7 @@ describe('BackchannelLogoutService', () => {
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'prune-jti' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 3 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
await service.handleLogout(makeLogoutToken({ sub: 'u1', jti: 'prune-jti' }));
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { AppSettingsService } from '../../app-settings/app-settings.service';
|
||||
import { OidcProviderService } from '../../app-settings/oidc-provider.service';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
import { OidcSessionRepository } from './oidc-session.repository';
|
||||
@@ -12,13 +12,24 @@ import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||||
return JSON.parse(payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BackchannelLogoutService {
|
||||
private readonly logger = new Logger(BackchannelLogoutService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly providerService: OidcProviderService,
|
||||
private readonly discovery: OidcDiscoveryService,
|
||||
private readonly tokenValidator: OidcTokenValidatorService,
|
||||
private readonly sessionRepo: OidcSessionRepository,
|
||||
@@ -26,18 +37,35 @@ export class BackchannelLogoutService {
|
||||
) {}
|
||||
|
||||
async handleLogout(logoutToken: string): Promise<void> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) return;
|
||||
// D2: Decode JWT without verification to find the issuer
|
||||
const unverified = decodeJwtPayload(logoutToken);
|
||||
if (!unverified?.iss) {
|
||||
this.logger.warn('[auth.oidc_backchannel_logout] [fail] error="cannot decode logout token issuer" - backchannel logout rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
const discovery = await this.discovery.getDiscoveryDoc(config.issuerUri);
|
||||
const issuerRaw = unverified.iss;
|
||||
if (typeof issuerRaw !== 'string') {
|
||||
this.logger.warn('[auth.oidc_backchannel_logout] [fail] error="cannot decode logout token issuer" - backchannel logout rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
const issuer = issuerRaw;
|
||||
const provider = await this.providerService.findByIssuerUri(issuer);
|
||||
if (!provider || !provider.enabled) {
|
||||
this.logger.warn(`[auth.oidc_backchannel_logout] [fail] issuer=${issuer} error="no matching enabled provider" - backchannel logout rejected`);
|
||||
return;
|
||||
}
|
||||
|
||||
const discovery = await this.discovery.getDiscoveryDoc(provider.issuerUri);
|
||||
|
||||
const claims = await this.tokenValidator.validateLogoutToken(logoutToken, {
|
||||
issuer: discovery.issuer,
|
||||
clientId: config.clientId,
|
||||
clientId: provider.clientId,
|
||||
jwksUri: discovery.jwksUri,
|
||||
});
|
||||
|
||||
// Replay prevention via DB — atomic insert, conflict = already used
|
||||
// Replay prevention via DB
|
||||
if (claims.jti) {
|
||||
const jti = typeof claims.jti === 'string' ? claims.jti : undefined;
|
||||
if (jti) {
|
||||
@@ -52,7 +80,6 @@ export class BackchannelLogoutService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune expired JTIs
|
||||
await this.db.delete(schema.oidcUsedJtis).where(lt(schema.oidcUsedJtis.expiresAt, new Date()));
|
||||
}
|
||||
}
|
||||
@@ -86,13 +113,11 @@ export class BackchannelLogoutService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revoke all refresh tokens
|
||||
await this.db
|
||||
.update(schema.refreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt)));
|
||||
|
||||
// Invalidate all JWTs via token version bump
|
||||
await this.userService.incrementTokenVersion(userId);
|
||||
|
||||
this.logger.log(`[auth.oidc_backchannel_logout] [end] userId=${userId} - backchannel logout completed`);
|
||||
|
||||
@@ -3,8 +3,9 @@ import { OidcGroupMappingService } from './oidc-group-mapping.service';
|
||||
function makeTx() {
|
||||
return {
|
||||
query: {
|
||||
oidcGroupMappings: { findMany: vi.fn() },
|
||||
userPermissions: { findMany: vi.fn() },
|
||||
oidcGroupMappings: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
oidcPermissionGrants: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
userPermissions: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
@@ -33,79 +34,66 @@ describe('OidcGroupMappingService', () => {
|
||||
|
||||
it('does nothing when no system mappings exist', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([]);
|
||||
await service.syncUserGroups(1, []);
|
||||
await service.syncUserGroups(1, 1, []);
|
||||
// Only delete for cleanup, no insert for permissions
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds desired permissions and skips existing ones', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
it('adds desired permissions from provider-scoped mappings', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users', providerId: 1 }]);
|
||||
db.tx.query.oidcPermissionGrants.findMany.mockResolvedValue([]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
await service.syncUserGroups(42, 1, ['admins']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
expect(db.tx.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes stale OIDC-managed permissions when user no longer has matching groups', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
it('removes stale grants when user no longer has matching groups', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users', providerId: 1 }]);
|
||||
db.tx.query.oidcPermissionGrants.findMany.mockResolvedValue([{ userId: 42, providerId: 1, permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, []);
|
||||
await service.syncUserGroups(42, 1, []);
|
||||
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not remove permissions not in oidc_group_mappings (manually-granted)', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([
|
||||
{ userId: 42, permissionName: 'manage_users' },
|
||||
{ userId: 42, permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, []);
|
||||
|
||||
// manage_users is OIDC-managed and should be removed; library_edit_metadata is not
|
||||
expect(db.tx.delete).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates when multiple group claims map to the same permission', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'superadmins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users', providerId: 1 },
|
||||
{ oidcGroupClaim: 'superadmins', permissionName: 'manage_users', providerId: 1 },
|
||||
]);
|
||||
db.tx.query.oidcPermissionGrants.findMany.mockResolvedValue([]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'superadmins']);
|
||||
await service.syncUserGroups(42, 1, ['admins', 'superadmins']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
expect(db.tx.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips mappings with null permissionName', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: null },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
{ oidcGroupClaim: 'admins', permissionName: null, providerId: 1 },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata', providerId: 1 },
|
||||
]);
|
||||
db.tx.query.oidcPermissionGrants.findMany.mockResolvedValue([]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
await service.syncUserGroups(42, 1, ['admins', 'editors']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'library_edit_metadata' }]);
|
||||
expect(db.tx.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not insert permissions user already has', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users', providerId: 1 }]);
|
||||
db.tx.query.oidcPermissionGrants.findMany.mockResolvedValue([{ userId: 42, providerId: 1, permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
await service.syncUserGroups(42, 1, ['admins']);
|
||||
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
// Provider grants unchanged, user perms unchanged
|
||||
// insert may be called for onConflictDoNothing but no new values
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,57 +14,134 @@ export class OidcGroupMappingService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Full-sync OIDC group permissions for a user.
|
||||
* Provider-scoped full-sync of OIDC group permissions.
|
||||
*
|
||||
* Adds permissions gained from the current group claims and removes permissions
|
||||
* that were previously granted via OIDC groups but are no longer in scope.
|
||||
*
|
||||
* Note: Any permission that exists in `oidc_group_mappings` is treated as
|
||||
* OIDC-managed. If an admin also manually granted the same permission, OIDC
|
||||
* management takes precedence — the permission will be removed if the user's
|
||||
* groups no longer entitle it.
|
||||
* For the given provider, calculates which permissions the user should have
|
||||
* based on their group claims and the provider's group mappings. Updates
|
||||
* `oidc_permission_grants` for provenance tracking, then materializes the
|
||||
* effective union of all provider grants into `user_permissions`.
|
||||
*/
|
||||
async syncUserGroups(userId: number, groups: string[]): Promise<void> {
|
||||
async syncUserGroups(userId: number, providerId: number, groups: string[]): Promise<void> {
|
||||
const start = Date.now();
|
||||
this.logger.log(`[auth.oidc_group_sync] [start] userId=${userId} groupCount=${groups.length} - group sync started`);
|
||||
this.logger.log(`[auth.oidc_group_sync] [start] userId=${userId} providerId=${providerId} groupCount=${groups.length} - group sync started`);
|
||||
|
||||
await this.db.transaction(async (tx) => {
|
||||
// All group mappings in the system — defines the OIDC-managed permission set.
|
||||
const allMappings = await tx.query.oidcGroupMappings.findMany();
|
||||
if (allMappings.length === 0) return;
|
||||
// Get mappings for this specific provider
|
||||
const providerMappings = await tx.query.oidcGroupMappings.findMany({
|
||||
where: eq(schema.oidcGroupMappings.providerId, providerId),
|
||||
});
|
||||
if (providerMappings.length === 0) {
|
||||
// No mappings for this provider - clean up any stale grants
|
||||
await tx
|
||||
.delete(schema.oidcPermissionGrants)
|
||||
.where(and(eq(schema.oidcPermissionGrants.userId, userId), eq(schema.oidcPermissionGrants.providerId, providerId)));
|
||||
return;
|
||||
}
|
||||
|
||||
const allOidcPermissions = new Set(allMappings.map((m) => m.permissionName).filter(Boolean) as string[]);
|
||||
|
||||
// Desired permissions based on the user's current groups.
|
||||
// Desired permissions based on user's groups and this provider's mappings
|
||||
const desired = new Set(
|
||||
allMappings.filter((m) => m.permissionName && groups.includes(m.oidcGroupClaim)).map((m) => m.permissionName as string),
|
||||
providerMappings.filter((m) => m.permissionName && groups.includes(m.oidcGroupClaim)).map((m) => m.permissionName as string),
|
||||
);
|
||||
|
||||
// Current user permissions that are OIDC-managed.
|
||||
// Current grants for this provider
|
||||
const currentGrants = await tx.query.oidcPermissionGrants.findMany({
|
||||
where: and(eq(schema.oidcPermissionGrants.userId, userId), eq(schema.oidcPermissionGrants.providerId, providerId)),
|
||||
});
|
||||
const currentGrantPerms = new Set(currentGrants.map((g) => g.permissionName));
|
||||
|
||||
const toAdd = [...desired].filter((p) => !currentGrantPerms.has(p));
|
||||
const toRemove = [...currentGrantPerms].filter((p) => !desired.has(p));
|
||||
|
||||
// Update provenance table
|
||||
if (toAdd.length > 0) {
|
||||
await tx
|
||||
.insert(schema.oidcPermissionGrants)
|
||||
.values(toAdd.map((permissionName) => ({ userId, providerId, permissionName })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
if (toRemove.length > 0) {
|
||||
await tx
|
||||
.delete(schema.oidcPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.oidcPermissionGrants.userId, userId),
|
||||
eq(schema.oidcPermissionGrants.providerId, providerId),
|
||||
inArray(schema.oidcPermissionGrants.permissionName, toRemove),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Now materialize: effective OIDC permissions = union across all providers
|
||||
const allGrants = await tx.query.oidcPermissionGrants.findMany({
|
||||
where: eq(schema.oidcPermissionGrants.userId, userId),
|
||||
});
|
||||
const effectiveOidcPerms = new Set(allGrants.map((g) => g.permissionName));
|
||||
|
||||
// All OIDC-managed permission names (from all providers)
|
||||
const allMappings = await tx.query.oidcGroupMappings.findMany();
|
||||
const allOidcPermissions = new Set(allMappings.map((m) => m.permissionName).filter(Boolean) as string[]);
|
||||
|
||||
// Current user permissions
|
||||
const currentUserPerms = await tx.query.userPermissions.findMany({
|
||||
where: eq(schema.userPermissions.userId, userId),
|
||||
});
|
||||
const currentOidcPerms = currentUserPerms.filter((p) => allOidcPermissions.has(p.permissionName)).map((p) => p.permissionName);
|
||||
const currentUserPermSet = new Set(currentUserPerms.map((p) => p.permissionName));
|
||||
|
||||
const toAdd = [...desired].filter((p) => !currentUserPerms.some((up) => up.permissionName === p));
|
||||
const toRemove = currentOidcPerms.filter((p) => !desired.has(p));
|
||||
// Add OIDC-granted permissions not yet in user_permissions
|
||||
const permsToAdd = [...effectiveOidcPerms].filter((p) => !currentUserPermSet.has(p));
|
||||
// Remove OIDC-managed permissions that are no longer granted by any provider
|
||||
const permsToRemove = currentUserPerms
|
||||
.filter((p) => allOidcPermissions.has(p.permissionName) && !effectiveOidcPerms.has(p.permissionName))
|
||||
.map((p) => p.permissionName);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
if (permsToAdd.length > 0) {
|
||||
await tx
|
||||
.insert(schema.userPermissions)
|
||||
.values(toAdd.map((permissionName) => ({ userId, permissionName })))
|
||||
.values(permsToAdd.map((permissionName) => ({ userId, permissionName })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
if (permsToRemove.length > 0) {
|
||||
await tx
|
||||
.delete(schema.userPermissions)
|
||||
.where(and(eq(schema.userPermissions.userId, userId), inArray(schema.userPermissions.permissionName, toRemove)));
|
||||
.where(and(eq(schema.userPermissions.userId, userId), inArray(schema.userPermissions.permissionName, permsToRemove)));
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[auth.oidc_group_sync] [end] userId=${userId} durationMs=${Date.now() - start} added=${toAdd.length} removed=${toRemove.length} - group sync completed`,
|
||||
`[auth.oidc_group_sync] [end] userId=${userId} providerId=${providerId} durationMs=${Date.now() - start} added=${permsToAdd.length} removed=${permsToRemove.length} - group sync completed`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async removeProviderGrants(userId: number, providerId: number): Promise<void> {
|
||||
await this.db.transaction(async (tx) => {
|
||||
// Remove grants from this provider
|
||||
await tx
|
||||
.delete(schema.oidcPermissionGrants)
|
||||
.where(and(eq(schema.oidcPermissionGrants.userId, userId), eq(schema.oidcPermissionGrants.providerId, providerId)));
|
||||
|
||||
// Recalculate effective permissions from remaining providers
|
||||
const remainingGrants = await tx.query.oidcPermissionGrants.findMany({
|
||||
where: eq(schema.oidcPermissionGrants.userId, userId),
|
||||
});
|
||||
const effectiveOidcPerms = new Set(remainingGrants.map((g) => g.permissionName));
|
||||
|
||||
// Get all OIDC-managed permission names
|
||||
const allMappings = await tx.query.oidcGroupMappings.findMany();
|
||||
const allOidcPermissions = new Set(allMappings.map((m) => m.permissionName).filter(Boolean) as string[]);
|
||||
|
||||
// Remove user permissions that were OIDC-managed but no longer granted
|
||||
const currentUserPerms = await tx.query.userPermissions.findMany({
|
||||
where: eq(schema.userPermissions.userId, userId),
|
||||
});
|
||||
const permsToRemove = currentUserPerms
|
||||
.filter((p) => allOidcPermissions.has(p.permissionName) && !effectiveOidcPerms.has(p.permissionName))
|
||||
.map((p) => p.permissionName);
|
||||
|
||||
if (permsToRemove.length > 0) {
|
||||
await tx
|
||||
.delete(schema.userPermissions)
|
||||
.where(and(eq(schema.userPermissions.userId, userId), inArray(schema.userPermissions.permissionName, permsToRemove)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('OidcStateService', () => {
|
||||
db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const state = await service.generate();
|
||||
const state = await service.generate(1);
|
||||
expect(typeof state).toBe('string');
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
@@ -30,8 +30,8 @@ describe('OidcStateService', () => {
|
||||
db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const a = await service.generate();
|
||||
const b = await service.generate();
|
||||
const a = await service.generate(1);
|
||||
const b = await service.generate(1);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
@@ -44,11 +44,12 @@ describe('OidcStateService', () => {
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const state = await service.generate();
|
||||
const state = await service.generate(1);
|
||||
|
||||
expect(insertMock).toHaveBeenCalledOnce();
|
||||
const [[{ state: insertedState, meta }]] = valuesMock.mock.calls;
|
||||
const [[{ state: insertedState, providerId, meta }]] = valuesMock.mock.calls;
|
||||
expect(insertedState).toBe(state);
|
||||
expect(providerId).toBe(1);
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
@@ -62,7 +63,7 @@ describe('OidcStateService', () => {
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
const metaPayload = { mode: 'link', userId: 42 };
|
||||
|
||||
await service.generate(metaPayload);
|
||||
await service.generate(1, metaPayload);
|
||||
|
||||
const [[{ meta }]] = valuesMock.mock.calls;
|
||||
expect(JSON.parse(meta as string)).toEqual(metaPayload);
|
||||
@@ -78,7 +79,7 @@ describe('OidcStateService', () => {
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const before = Date.now();
|
||||
const state = await service.generate();
|
||||
const state = await service.generate(1);
|
||||
const after = Date.now();
|
||||
|
||||
const [[{ state: insertedState, expiresAt }]] = valuesMock.mock.calls;
|
||||
@@ -95,7 +96,7 @@ describe('OidcStateService', () => {
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
await service.generate();
|
||||
await service.generate(1);
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
expect(whereMock).toHaveBeenCalled();
|
||||
@@ -107,7 +108,7 @@ describe('OidcStateService', () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: null }]),
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', providerId: 1, meta: null }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
@@ -116,6 +117,7 @@ describe('OidcStateService', () => {
|
||||
|
||||
const result = await service.validateAndConsume('valid-state');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.providerId).toBe(1);
|
||||
expect(result.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -124,7 +126,7 @@ describe('OidcStateService', () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: JSON.stringify(metaPayload) }]),
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', providerId: 1, meta: JSON.stringify(metaPayload) }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
|
||||
@@ -20,19 +20,19 @@ export class OidcStateService {
|
||||
this.ttlMs = this.configService.get<number>('oidcRuntime.stateTtlMs') ?? 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
async generate(meta?: Record<string, unknown>): Promise<string> {
|
||||
async generate(providerId: number, meta?: Record<string, unknown>): Promise<string> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + this.ttlMs);
|
||||
|
||||
await Promise.all([
|
||||
this.db.delete(schema.oidcStates).where(lt(schema.oidcStates.expiresAt, new Date())),
|
||||
this.db.insert(schema.oidcStates).values({ state, expiresAt, meta: meta ? JSON.stringify(meta) : null }),
|
||||
this.db.insert(schema.oidcStates).values({ state, providerId, expiresAt, meta: meta ? JSON.stringify(meta) : null }),
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async validateAndConsume(state: string): Promise<{ valid: boolean; meta?: Record<string, unknown> }> {
|
||||
async validateAndConsume(state: string): Promise<{ valid: boolean; providerId?: number; meta?: Record<string, unknown> }> {
|
||||
const deleted = await this.db
|
||||
.delete(schema.oidcStates)
|
||||
.where(and(eq(schema.oidcStates.state, state), gt(schema.oidcStates.expiresAt, new Date())))
|
||||
@@ -41,6 +41,6 @@ export class OidcStateService {
|
||||
if (deleted.length === 0) return { valid: false };
|
||||
const row = deleted[0];
|
||||
const meta = row.meta ? (JSON.parse(row.meta) as Record<string, unknown>) : undefined;
|
||||
return { valid: true, meta };
|
||||
return { valid: true, providerId: row.providerId, meta };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,28 @@ import { OidcService } from './oidc.service';
|
||||
const APP_URL = 'http://localhost:5173';
|
||||
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
|
||||
|
||||
const PROVIDER = {
|
||||
id: 1,
|
||||
slug: 'keycloak',
|
||||
displayName: 'Keycloak',
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
scopes: 'openid profile email',
|
||||
iconUrl: null,
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: false, defaultPermissionNames: [] },
|
||||
displayOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: false, defaultPermissionNames: [] },
|
||||
}),
|
||||
const providerService = {
|
||||
findBySlugOrFail: vi.fn().mockResolvedValue(PROVIDER),
|
||||
findByIdOrFail: vi.fn().mockResolvedValue(PROVIDER),
|
||||
findByIssuerUri: vi.fn().mockResolvedValue(PROVIDER),
|
||||
};
|
||||
const discovery = {
|
||||
getDiscoveryDoc: vi.fn().mockResolvedValue({
|
||||
@@ -36,23 +48,31 @@ function makeService() {
|
||||
};
|
||||
const stateService = {
|
||||
generate: vi.fn().mockResolvedValue('state-token'),
|
||||
validateAndConsume: vi.fn().mockResolvedValue({ valid: true }),
|
||||
validateAndConsume: vi.fn().mockResolvedValue({ valid: true, providerId: 1 }),
|
||||
};
|
||||
const sessionRepo = {
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const groupMapping = {
|
||||
syncUserGroups: vi.fn().mockResolvedValue(undefined),
|
||||
removeProviderGrants: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const backchannelLogout = {
|
||||
handleLogout: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const identityRepo = {
|
||||
findByProviderAndSubject: vi.fn().mockResolvedValue(null),
|
||||
findByIssuerAndSubject: vi.fn().mockResolvedValue(null),
|
||||
findByUser: vi.fn().mockResolvedValue([]),
|
||||
findByUserAndProvider: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
remove: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
countByUser: vi.fn().mockResolvedValue(1),
|
||||
};
|
||||
const userService = {
|
||||
findByOidcSubject: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByUsername: vi.fn(),
|
||||
linkOidcIdentity: vi.fn(),
|
||||
unlinkOidcIdentity: vi.fn(),
|
||||
getUserOidcIdentity: vi.fn(),
|
||||
findPasswordHashById: vi.fn(),
|
||||
createOidcUser: vi.fn(),
|
||||
setPermissionsDirectly: vi.fn(),
|
||||
@@ -72,7 +92,7 @@ function makeService() {
|
||||
};
|
||||
|
||||
const service = new OidcService(
|
||||
appSettings as never,
|
||||
providerService as never,
|
||||
discovery as never,
|
||||
tokenClient as never,
|
||||
tokenValidator as never,
|
||||
@@ -81,13 +101,27 @@ function makeService() {
|
||||
sessionRepo as never,
|
||||
groupMapping as never,
|
||||
backchannelLogout as never,
|
||||
identityRepo as never,
|
||||
userService as never,
|
||||
authService as never,
|
||||
auditEvents as never,
|
||||
configService as never,
|
||||
);
|
||||
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo, stateService, auditEvents, discovery, tokenValidator };
|
||||
return {
|
||||
service,
|
||||
providerService,
|
||||
claimExtractor,
|
||||
userService,
|
||||
authService,
|
||||
sessionRepo,
|
||||
stateService,
|
||||
auditEvents,
|
||||
discovery,
|
||||
tokenValidator,
|
||||
identityRepo,
|
||||
groupMapping,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_CALLBACK = {
|
||||
@@ -100,15 +134,15 @@ const BASE_CALLBACK = {
|
||||
|
||||
describe('OidcService', () => {
|
||||
describe('generateState', () => {
|
||||
it('throws when OIDC is disabled', async () => {
|
||||
const { service, appSettings } = makeService();
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
await expect(service.generateState()).rejects.toThrow(UnauthorizedException);
|
||||
it('throws when provider is disabled', async () => {
|
||||
const { service, providerService } = makeService();
|
||||
providerService.findBySlugOrFail.mockResolvedValue({ ...PROVIDER, enabled: false });
|
||||
await expect(service.generateState('keycloak')).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns state and authorizationEndpoint', async () => {
|
||||
const { service } = makeService();
|
||||
const result = await service.generateState();
|
||||
const result = await service.generateState('keycloak');
|
||||
expect(result).toMatchObject({ state: 'state-token', authorizationEndpoint: 'https://issuer.example/auth' });
|
||||
});
|
||||
});
|
||||
@@ -117,18 +151,24 @@ describe('OidcService', () => {
|
||||
it('generates state with link mode meta', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.generate.mockResolvedValue('link-state');
|
||||
const result = await service.generateLinkState(42);
|
||||
expect(stateService.generate).toHaveBeenCalledWith({ mode: 'link', userId: 42 });
|
||||
const result = await service.generateLinkState(42, 'keycloak');
|
||||
expect(stateService.generate).toHaveBeenCalledWith(1, { mode: 'link', userId: 42 });
|
||||
expect(result.state).toBe('link-state');
|
||||
});
|
||||
|
||||
it('throws when already linked to provider', async () => {
|
||||
const { service, identityRepo } = makeService();
|
||||
identityRepo.findByUserAndProvider.mockResolvedValue({ id: 1 });
|
||||
await expect(service.generateLinkState(42, 'keycloak')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePreviewState', () => {
|
||||
it('generates state with preview mode meta', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.generate.mockResolvedValue('preview-state');
|
||||
const result = await service.generatePreviewState();
|
||||
expect(stateService.generate).toHaveBeenCalledWith({ mode: 'preview' });
|
||||
const result = await service.generatePreviewState('keycloak');
|
||||
expect(stateService.generate).toHaveBeenCalledWith(1, { mode: 'preview' });
|
||||
expect(result.state).toBe('preview-state');
|
||||
});
|
||||
});
|
||||
@@ -153,27 +193,20 @@ describe('OidcService', () => {
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('returns login mode response for existing OIDC user', async () => {
|
||||
const { service, userService, authService } = makeService();
|
||||
const user = { id: 5, username: 'u1', active: true };
|
||||
userService.findByOidcSubject.mockResolvedValue(user);
|
||||
it('returns login mode response for existing identity', async () => {
|
||||
const { service, identityRepo, userService, authService } = makeService();
|
||||
const user = { id: 5, username: 'u1', active: true, permissions: [] };
|
||||
identityRepo.findByProviderAndSubject.mockResolvedValue({ userId: 5 });
|
||||
userService.findById.mockResolvedValue(user);
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'at', user: { id: 5 } });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'login', accessToken: 'at' });
|
||||
});
|
||||
|
||||
it('emits OidcLogin audit event on successful login', async () => {
|
||||
const { service, userService, auditEvents } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue({ id: 5, username: 'u1', active: true });
|
||||
|
||||
await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.login' }));
|
||||
});
|
||||
|
||||
it('returns preview mode response with raw+mapped claims', async () => {
|
||||
const { service, stateService, claimExtractor, tokenValidator } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, meta: { mode: 'preview' } });
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, providerId: 1, meta: { mode: 'preview' } });
|
||||
tokenValidator.validateIdToken.mockResolvedValue({ sub: 'sub-1', email: 'u1@example.com' });
|
||||
claimExtractor.extract.mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: ['g1'] });
|
||||
|
||||
@@ -181,113 +214,62 @@ describe('OidcService', () => {
|
||||
expect(result).toMatchObject({ mode: 'preview', claims: { mapped: { username: 'u1', email: 'u1@example.com' } } });
|
||||
});
|
||||
|
||||
it('returns link mode response and emits OidcIdentityLinked', async () => {
|
||||
const { service, stateService, userService, auditEvents } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, meta: { mode: 'link', userId: 42 } });
|
||||
it('returns link mode response and creates identity', async () => {
|
||||
const { service, stateService, identityRepo, auditEvents } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: true, providerId: 1, meta: { mode: 'link', userId: 42 } });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'link', linked: true });
|
||||
expect(userService.linkOidcIdentity).toHaveBeenCalledWith(42, 'sub-1', 'https://issuer.example', undefined);
|
||||
expect(identityRepo.create).toHaveBeenCalled();
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.identity_linked' }));
|
||||
});
|
||||
|
||||
it('rejects inactive user', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue({ id: 5, username: 'u1', active: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('throws when OIDC is disabled', async () => {
|
||||
const { service, appSettings } = makeService();
|
||||
appSettings.getOidcConfig.mockResolvedValue({ enabled: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('auto-provisions a new user and emits OidcUserProvisioned', async () => {
|
||||
const { service, userService, appSettings, auditEvents } = makeService();
|
||||
const newUser = { id: 9, username: 'u1', active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: [] },
|
||||
});
|
||||
userService.findByOidcSubject.mockResolvedValue(null);
|
||||
userService.createOidcUser.mockResolvedValue(newUser);
|
||||
|
||||
await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.user_provisioned' }));
|
||||
});
|
||||
|
||||
it('reuses an OIDC user when auto-provision races on unique constraint', async () => {
|
||||
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
|
||||
const existingUser = { id: 7, username: 'u1', active: true };
|
||||
appSettings.getOidcConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
issuerUri: 'https://issuer.example',
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: true, allowLocalLinking: false, defaultPermissionNames: ['library_download'] },
|
||||
});
|
||||
claimExtractor.extract.mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] });
|
||||
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
|
||||
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token', user: {} });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
expect(result).toMatchObject({ mode: 'login', accessToken: 'token' });
|
||||
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
|
||||
expect(sessionRepo.create).toHaveBeenCalledWith(expect.objectContaining({ userId: 7, oidcSubject: 'sub-1' }));
|
||||
});
|
||||
|
||||
it('throws USER_NOT_PROVISIONED when no user and auto-provision is off', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findByOidcSubject.mockResolvedValue(null);
|
||||
const { service, identityRepo, userService } = makeService();
|
||||
identityRepo.findByProviderAndSubject.mockResolvedValue({ userId: 5 });
|
||||
userService.findById.mockResolvedValue({ id: 5, username: 'u1', active: false, permissions: [] });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinkedIdentity', () => {
|
||||
it('delegates to userService.getUserOidcIdentity', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.getUserOidcIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
const result = await service.getLinkedIdentity(5);
|
||||
expect(userService.getUserOidcIdentity).toHaveBeenCalledWith(5);
|
||||
expect(result).toEqual({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
describe('getLinkedIdentities', () => {
|
||||
it('delegates to identityRepo.findByUser', async () => {
|
||||
const { service, identityRepo } = makeService();
|
||||
const identities = [{ id: 1, providerId: 1, oidcSubject: 'sub-1' }];
|
||||
identityRepo.findByUser.mockResolvedValue(identities);
|
||||
const result = await service.getLinkedIdentities(5);
|
||||
expect(identityRepo.findByUser).toHaveBeenCalledWith(5);
|
||||
expect(result).toEqual(identities);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlinkIdentity', () => {
|
||||
it('throws BadRequestException when password is incorrect', async () => {
|
||||
const { service, userService } = makeService();
|
||||
// Hash of 'correct-password' would be stored; we supply a bcrypt hash of something else
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash('correct-password', 4);
|
||||
userService.findPasswordHashById.mockResolvedValue(hash);
|
||||
|
||||
await expect(service.unlinkIdentity(5, 'wrong-password')).rejects.toThrow(BadRequestException);
|
||||
expect(userService.unlinkOidcIdentity).not.toHaveBeenCalled();
|
||||
await expect(service.unlinkIdentity(5, 1, 'wrong-password')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when user not found', async () => {
|
||||
const { service, userService } = makeService();
|
||||
userService.findPasswordHashById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlinkIdentity(99, 'any-password')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.unlinkIdentity(99, 1, 'any-password')).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('unlinks identity on correct password and emits OidcIdentityUnlinked', async () => {
|
||||
const { service, userService, auditEvents } = makeService();
|
||||
const { service, userService, identityRepo, auditEvents } = makeService();
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash('correct-password', 4);
|
||||
userService.findPasswordHashById.mockResolvedValue(hash);
|
||||
userService.getUserOidcIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
userService.findById.mockResolvedValue({ id: 5, provisioningMethod: 'local' });
|
||||
identityRepo.findByUserAndProvider.mockResolvedValue({ id: 1, oidcSubject: 'sub-1', oidcIssuer: 'https://issuer.example' });
|
||||
|
||||
await service.unlinkIdentity(5, 'correct-password');
|
||||
expect(userService.unlinkOidcIdentity).toHaveBeenCalledWith(5);
|
||||
await service.unlinkIdentity(5, 1, 'correct-password');
|
||||
expect(identityRepo.remove).toHaveBeenCalledWith(5, 1);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.identity_unlinked' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { compare } from 'bcryptjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { AuditAction, AuditResource, OidcCallbackResponse, OidcErrorCode, Permission } from '@projectx/types';
|
||||
import type { OidcAutoProvision, OidcClaimMapping } from '@projectx/types';
|
||||
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../../audit/audit-events.service';
|
||||
import { AppSettingsService } from '../../app-settings/app-settings.service';
|
||||
import { OidcProviderService } from '../../app-settings/oidc-provider.service';
|
||||
import { OidcIdentityRepository } from '../../user/oidc-identity.repository';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { BackchannelLogoutService } from './backchannel-logout.service';
|
||||
@@ -45,7 +47,7 @@ export class OidcService {
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly providerService: OidcProviderService,
|
||||
private readonly discovery: OidcDiscoveryService,
|
||||
private readonly tokenClient: OidcTokenClientService,
|
||||
private readonly tokenValidator: OidcTokenValidatorService,
|
||||
@@ -54,6 +56,7 @@ export class OidcService {
|
||||
private readonly sessionRepo: OidcSessionRepository,
|
||||
private readonly groupMapping: OidcGroupMappingService,
|
||||
private readonly backchannelLogout: BackchannelLogoutService,
|
||||
private readonly identityRepo: OidcIdentityRepository,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
@@ -62,24 +65,37 @@ export class OidcService {
|
||||
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
async generateState(): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate(), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const provider = await this.providerService.findBySlugOrFail(providerSlug);
|
||||
if (!provider.enabled) throw new UnauthorizedException('OIDC provider is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate(provider.id), this.discovery.getDiscoveryDoc(provider.issuerUri)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
async generateLinkState(userId: number): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate({ mode: 'link', userId }), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
async generateLinkState(
|
||||
userId: number,
|
||||
providerSlug: string,
|
||||
): Promise<{ state: string; authorizationEndpoint: string; clientId: string; scopes: string }> {
|
||||
const provider = await this.providerService.findBySlugOrFail(providerSlug);
|
||||
if (!provider.enabled) throw new UnauthorizedException('OIDC provider is not enabled');
|
||||
|
||||
const existing = await this.identityRepo.findByUserAndProvider(userId, provider.id);
|
||||
if (existing) throw new BadRequestException('You already have an identity linked to this provider');
|
||||
|
||||
const [state, disc] = await Promise.all([
|
||||
this.stateService.generate(provider.id, { mode: 'link', userId }),
|
||||
this.discovery.getDiscoveryDoc(provider.issuerUri),
|
||||
]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint, clientId: provider.clientId, scopes: provider.scopes };
|
||||
}
|
||||
|
||||
async generatePreviewState(): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const [state, disc] = await Promise.all([this.stateService.generate({ mode: 'preview' }), this.discovery.getDiscoveryDoc(config.issuerUri)]);
|
||||
async generatePreviewState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||
const provider = await this.providerService.findBySlugOrFail(providerSlug);
|
||||
if (!provider.enabled) throw new UnauthorizedException('OIDC provider is not enabled');
|
||||
const [state, disc] = await Promise.all([
|
||||
this.stateService.generate(provider.id, { mode: 'preview' }),
|
||||
this.discovery.getDiscoveryDoc(provider.issuerUri),
|
||||
]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
@@ -87,19 +103,34 @@ export class OidcService {
|
||||
return this.backchannelLogout.handleLogout(logoutToken);
|
||||
}
|
||||
|
||||
async getLinkedIdentity(userId: number) {
|
||||
return this.userService.getUserOidcIdentity(userId);
|
||||
async getLinkedIdentities(userId: number) {
|
||||
return this.identityRepo.findByUser(userId);
|
||||
}
|
||||
|
||||
async unlinkIdentity(userId: number, password: string): Promise<void> {
|
||||
async unlinkIdentity(userId: number, providerId: number, password: string): Promise<void> {
|
||||
const hash = await this.userService.findPasswordHashById(userId);
|
||||
if (!hash) throw new BadRequestException('User not found');
|
||||
|
||||
const valid = await compare(password, hash);
|
||||
if (!valid) throw new BadRequestException('Incorrect password');
|
||||
|
||||
const identity = await this.userService.getUserOidcIdentity(userId);
|
||||
await this.userService.unlinkOidcIdentity(userId);
|
||||
const identity = await this.identityRepo.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) throw new NotFoundException('No identity linked to this provider');
|
||||
|
||||
// D4: Check if user has local password OR another linked identity
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) throw new BadRequestException('User not found');
|
||||
|
||||
const identityCount = await this.identityRepo.countByUser(userId);
|
||||
const hasLocalAuth = user.provisioningMethod !== 'oidc';
|
||||
if (!hasLocalAuth && identityCount <= 1) {
|
||||
throw new BadRequestException('Cannot unlink: this is your only authentication method. Set a password first.');
|
||||
}
|
||||
|
||||
await this.identityRepo.remove(userId, providerId);
|
||||
|
||||
// Clean up provider-scoped permission grants
|
||||
await this.groupMapping.removeProviderGrants(userId, providerId);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId,
|
||||
@@ -107,7 +138,7 @@ export class OidcService {
|
||||
action: AuditAction.OidcIdentityUnlinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: userId,
|
||||
description: `User unlinked OIDC identity (issuer: ${identity?.oidcIssuer ?? 'unknown'})`,
|
||||
description: `User unlinked OIDC identity (issuer: ${identity.oidcIssuer ?? 'unknown'})`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,23 +147,25 @@ export class OidcService {
|
||||
reply: FastifyReply,
|
||||
): Promise<OidcCallbackResponse> {
|
||||
const stateResult = await this.stateService.validateAndConsume(params.state);
|
||||
if (!stateResult.valid) {
|
||||
if (!stateResult.valid || !stateResult.providerId) {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your login session expired. Please try signing in again.',
|
||||
errorCode: OidcErrorCode.STATE_EXPIRED,
|
||||
});
|
||||
}
|
||||
|
||||
const config = await this.appSettings.getOidcConfig();
|
||||
if (!config.enabled) throw new UnauthorizedException('OIDC is not enabled');
|
||||
const provider = await this.providerService.findByIdOrFail(stateResult.providerId);
|
||||
if (!provider.enabled) throw new UnauthorizedException('OIDC provider is not enabled');
|
||||
|
||||
const claimMapping = provider.claimMapping as OidcClaimMapping;
|
||||
const autoProvision = provider.autoProvision as OidcAutoProvision;
|
||||
|
||||
// P2-9: Validate redirect URI against allowed app URL
|
||||
const allowedRedirectUri = `${this.appUrl}/oauth2-callback`;
|
||||
if (normalizeRedirectUri(params.redirectUri) !== normalizeRedirectUri(allowedRedirectUri)) {
|
||||
throw new BadRequestException(`Redirect URI is not allowed: ${params.redirectUri}`);
|
||||
}
|
||||
|
||||
const disc = await this.discovery.getDiscoveryDoc(config.issuerUri);
|
||||
const disc = await this.discovery.getDiscoveryDoc(provider.issuerUri);
|
||||
|
||||
let tokens: Awaited<ReturnType<typeof this.tokenClient.exchangeCode>>;
|
||||
try {
|
||||
@@ -141,8 +174,8 @@ export class OidcService {
|
||||
codeVerifier: params.codeVerifier,
|
||||
redirectUri: params.redirectUri,
|
||||
tokenEndpoint: disc.tokenEndpoint,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret ?? '',
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
@@ -153,7 +186,7 @@ export class OidcService {
|
||||
|
||||
const idTokenClaims = await this.tokenValidator.validateIdToken(tokens.idToken, {
|
||||
issuer: disc.issuer,
|
||||
clientId: config.clientId,
|
||||
clientId: provider.clientId,
|
||||
nonce: params.nonce,
|
||||
jwksUri: disc.jwksUri,
|
||||
});
|
||||
@@ -163,7 +196,7 @@ export class OidcService {
|
||||
userInfoClaims = await this.tokenClient.fetchUserInfo(disc.userinfoEndpoint, tokens.accessToken);
|
||||
}
|
||||
|
||||
const claims = this.claimExtractor.extract(idTokenClaims as Record<string, unknown>, userInfoClaims, config.claimMapping);
|
||||
const claims = this.claimExtractor.extract(idTokenClaims as Record<string, unknown>, userInfoClaims, claimMapping);
|
||||
if (!claims.subject) {
|
||||
this.logger.warn('[auth.oidc_callback] [fail] errorClass=UnauthorizedException error="missing sub claim" - OIDC callback failed');
|
||||
throw new UnauthorizedException({
|
||||
@@ -186,7 +219,17 @@ export class OidcService {
|
||||
|
||||
if (mode === 'link') {
|
||||
const targetUserId = stateResult.meta?.userId as number;
|
||||
await this.userService.linkOidcIdentity(targetUserId, claims.subject, disc.issuer, claims.avatarUrl);
|
||||
await this.identityRepo.create({
|
||||
userId: targetUserId,
|
||||
providerId: provider.id,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: disc.issuer,
|
||||
});
|
||||
|
||||
// Also update avatar if available
|
||||
if (claims.avatarUrl) {
|
||||
await this.userService.linkOidcIdentity(targetUserId, claims.subject, disc.issuer, claims.avatarUrl);
|
||||
}
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: targetUserId,
|
||||
@@ -201,11 +244,12 @@ export class OidcService {
|
||||
}
|
||||
|
||||
// Default: login flow
|
||||
const user = await this.findOrProvisionUser(claims, config, disc.issuer);
|
||||
const user = await this.findOrProvisionUser(claims, provider, disc.issuer, autoProvision);
|
||||
|
||||
const sid = (idTokenClaims as Record<string, unknown>).sid ? String((idTokenClaims as Record<string, unknown>).sid) : undefined;
|
||||
await this.sessionRepo.create({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: disc.issuer,
|
||||
oidcSessionId: sid,
|
||||
@@ -229,17 +273,29 @@ export class OidcService {
|
||||
|
||||
private async findOrProvisionUser(
|
||||
claims: { subject: string; username: string; name: string; email?: string; avatarUrl?: string; groups: string[] },
|
||||
config: Awaited<ReturnType<AppSettingsService['getOidcConfig']>>,
|
||||
provider: Awaited<ReturnType<OidcProviderService['findByIdOrFail']>>,
|
||||
issuer: string,
|
||||
autoProvision: OidcAutoProvision,
|
||||
) {
|
||||
let user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
// Look up by identity table first
|
||||
const identity = await this.identityRepo.findByProviderAndSubject(provider.id, claims.subject);
|
||||
let user = identity ? await this.userService.findById(identity.userId) : null;
|
||||
|
||||
if (!user && config.autoProvision.allowLocalLinking) {
|
||||
if (!user && autoProvision.allowLocalLinking) {
|
||||
// Case-insensitive username matching for auto-link
|
||||
const byUsername = await this.userService.findByUsername(claims.username);
|
||||
if (byUsername) {
|
||||
let linkConflict: unknown;
|
||||
try {
|
||||
await this.identityRepo.create({
|
||||
userId: byUsername.id,
|
||||
providerId: provider.id,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: issuer,
|
||||
});
|
||||
// Also maintain legacy columns for backward compatibility
|
||||
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: byUsername.id,
|
||||
actorUsername: byUsername.username,
|
||||
@@ -252,7 +308,8 @@ export class OidcService {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
linkConflict = error;
|
||||
}
|
||||
user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
const recheckIdentity = await this.identityRepo.findByProviderAndSubject(provider.id, claims.subject);
|
||||
user = recheckIdentity ? await this.userService.findById(recheckIdentity.userId) : null;
|
||||
if (!user && linkConflict) {
|
||||
throw toThrowable(linkConflict, 'OIDC identity conflict');
|
||||
}
|
||||
@@ -260,9 +317,9 @@ export class OidcService {
|
||||
}
|
||||
|
||||
let createdUser = false;
|
||||
if (!user && config.autoProvision.enabled) {
|
||||
if (!user && autoProvision.enabled) {
|
||||
try {
|
||||
user = await this.userService.createOidcUser({
|
||||
const rawUser = await this.userService.createOidcUser({
|
||||
username: claims.username,
|
||||
name: claims.name,
|
||||
email: claims.email,
|
||||
@@ -271,14 +328,30 @@ export class OidcService {
|
||||
avatarUrl: claims.avatarUrl,
|
||||
});
|
||||
createdUser = true;
|
||||
|
||||
// Create identity link in new table
|
||||
await this.identityRepo.create({
|
||||
userId: rawUser.id,
|
||||
providerId: provider.id,
|
||||
oidcSubject: claims.subject,
|
||||
oidcIssuer: issuer,
|
||||
});
|
||||
|
||||
user = await this.userService.findById(rawUser.id);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
if (!user) throw toThrowable(error, 'OIDC user provisioning conflict');
|
||||
const recheckIdentity = await this.identityRepo.findByProviderAndSubject(provider.id, claims.subject);
|
||||
user = recheckIdentity ? await this.userService.findById(recheckIdentity.userId) : null;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your account has not been set up. Contact your administrator for access.',
|
||||
errorCode: OidcErrorCode.USER_NOT_PROVISIONED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (createdUser && config.autoProvision.defaultPermissionNames?.length) {
|
||||
await this.userService.setPermissionsDirectly(user!.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
if (createdUser && autoProvision.defaultPermissionNames?.length) {
|
||||
await this.userService.setPermissionsDirectly(user!.id, autoProvision.defaultPermissionNames as Permission[]);
|
||||
}
|
||||
|
||||
if (createdUser && user) {
|
||||
@@ -307,7 +380,7 @@ export class OidcService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.groupMapping.syncUserGroups(user.id, claims.groups);
|
||||
await this.groupMapping.syncUserGroups(user.id, provider.id, claims.groups);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db/db.module';
|
||||
import * as schema from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@Injectable()
|
||||
export class OidcIdentityRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findByProviderAndSubject(providerId: number, oidcSubject: string) {
|
||||
return this.db.query.oidcIdentities.findFirst({
|
||||
where: and(eq(schema.oidcIdentities.providerId, providerId), eq(schema.oidcIdentities.oidcSubject, oidcSubject)),
|
||||
});
|
||||
}
|
||||
|
||||
async findByIssuerAndSubject(oidcIssuer: string, oidcSubject: string) {
|
||||
return this.db.query.oidcIdentities.findFirst({
|
||||
where: and(eq(schema.oidcIdentities.oidcIssuer, oidcIssuer), eq(schema.oidcIdentities.oidcSubject, oidcSubject)),
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(userId: number) {
|
||||
return this.db
|
||||
.select({
|
||||
id: schema.oidcIdentities.id,
|
||||
providerId: schema.oidcIdentities.providerId,
|
||||
providerSlug: schema.oidcProviders.slug,
|
||||
providerName: schema.oidcProviders.displayName,
|
||||
providerIconUrl: schema.oidcProviders.iconUrl,
|
||||
oidcSubject: schema.oidcIdentities.oidcSubject,
|
||||
oidcIssuer: schema.oidcIdentities.oidcIssuer,
|
||||
linkedAt: schema.oidcIdentities.linkedAt,
|
||||
})
|
||||
.from(schema.oidcIdentities)
|
||||
.innerJoin(schema.oidcProviders, eq(schema.oidcIdentities.providerId, schema.oidcProviders.id))
|
||||
.where(eq(schema.oidcIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async findByUserAndProvider(userId: number, providerId: number) {
|
||||
return this.db.query.oidcIdentities.findFirst({
|
||||
where: and(eq(schema.oidcIdentities.userId, userId), eq(schema.oidcIdentities.providerId, providerId)),
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { userId: number; providerId: number; oidcSubject: string; oidcIssuer: string }) {
|
||||
const [row] = await this.db.insert(schema.oidcIdentities).values(data).returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async remove(userId: number, providerId: number) {
|
||||
const [row] = await this.db
|
||||
.delete(schema.oidcIdentities)
|
||||
.where(and(eq(schema.oidcIdentities.userId, userId), eq(schema.oidcIdentities.providerId, providerId)))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async removeAllForUser(userId: number) {
|
||||
await this.db.delete(schema.oidcIdentities).where(eq(schema.oidcIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async countByUser(userId: number): Promise<number> {
|
||||
const rows = await this.db.select({ id: schema.oidcIdentities.id }).from(schema.oidcIdentities).where(eq(schema.oidcIdentities.userId, userId));
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { OidcIdentityRepository } from './oidc-identity.repository';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserAvatarStorageService } from './user-avatar-storage.service';
|
||||
import { UserAvatarService } from './user-avatar.service';
|
||||
@@ -10,7 +11,19 @@ import { UserService } from './user.service';
|
||||
describe('UserModule', () => {
|
||||
it('registers expected controller/providers/exports', () => {
|
||||
expect(Reflect.getMetadata('controllers', UserModule)).toEqual([UserController]);
|
||||
expect(Reflect.getMetadata('providers', UserModule)).toEqual([UserService, UserRepository, UserAvatarService, UserAvatarStorageService]);
|
||||
expect(Reflect.getMetadata('exports', UserModule)).toEqual([UserService, UserRepository, UserAvatarService, UserAvatarStorageService]);
|
||||
expect(Reflect.getMetadata('providers', UserModule)).toEqual([
|
||||
UserService,
|
||||
UserRepository,
|
||||
UserAvatarService,
|
||||
UserAvatarStorageService,
|
||||
OidcIdentityRepository,
|
||||
]);
|
||||
expect(Reflect.getMetadata('exports', UserModule)).toEqual([
|
||||
UserService,
|
||||
UserRepository,
|
||||
UserAvatarService,
|
||||
UserAvatarStorageService,
|
||||
OidcIdentityRepository,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OidcIdentityRepository } from './oidc-identity.repository';
|
||||
import { UserAvatarStorageService } from './user-avatar-storage.service';
|
||||
import { UserAvatarService } from './user-avatar.service';
|
||||
import { UserController } from './user.controller';
|
||||
@@ -8,7 +9,7 @@ import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
providers: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService],
|
||||
exports: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService],
|
||||
providers: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService, OidcIdentityRepository],
|
||||
exports: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService, OidcIdentityRepository],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
Reference in New Issue
Block a user