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:
neon
2026-04-13 20:06:27 -06:00
parent 35594e849e
commit 71ba6cb566
36 changed files with 2624 additions and 816 deletions
+25 -21
View File
@@ -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) {
+13 -17
View File
@@ -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"
+519 -274
View File
@@ -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>
+35
View File
@@ -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,
+52 -1
View File
@@ -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": {},
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1776150932210,
"when": 1776162198181,
"tag": "0000_baseline",
"breakpoints": true
}
+84 -7
View File
@@ -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'
);
}
+12 -11
View File
@@ -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', () => {
+15 -15
View File
@@ -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);
}
}
+15 -5
View File
@@ -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({
+4 -5
View File
@@ -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 };
}
}
+92 -110
View File
@@ -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' }));
});
});
+117 -44
View File
@@ -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;
}
}
+15 -2
View File
@@ -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,
]);
});
});
+3 -2
View File
@@ -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 {}