mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: enhance OIDC flow with audit logs and improved error handling
- add audit events for OIDC login and user provisioning - improve callback error messages and state validation - add OIDC provider schema and initial migration
This commit is contained in:
@@ -278,9 +278,10 @@ async function handleOidcLogin() {
|
||||
<button
|
||||
type="button"
|
||||
:disabled="oidcLoading"
|
||||
class="w-full 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"
|
||||
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>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { AuthResponse } from '@projectx/types'
|
||||
import type { OidcCallbackResponse } from '@projectx/types'
|
||||
import { OidcErrorCode } from '@projectx/types'
|
||||
import { setAccessToken } from '@/lib/api'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
import { useOidc } from './composables/useOidc'
|
||||
import { useOidc, OidcLoginError } from './composables/useOidc'
|
||||
|
||||
const OIDC_ERROR_MESSAGES: Record<string, string> = {
|
||||
[OidcErrorCode.STATE_EXPIRED]: 'Your login session expired. Please try signing in again.',
|
||||
[OidcErrorCode.TOKEN_EXCHANGE_FAILED]: 'Could not complete sign-in with your provider. Please try again or contact your administrator.',
|
||||
[OidcErrorCode.USER_NOT_PROVISIONED]: 'Your account has not been set up. Contact your administrator for access.',
|
||||
[OidcErrorCode.USER_INACTIVE]: 'Your account has been deactivated. Contact your administrator.',
|
||||
[OidcErrorCode.PROVIDER_ERROR]: 'Your identity provider returned an error. Please try again.',
|
||||
}
|
||||
|
||||
const PROVIDER_ERROR_DESCRIPTIONS: Record<string, string> = {
|
||||
access_denied: 'Access was denied by the identity provider.',
|
||||
login_required: 'Authentication is required. Please sign in again.',
|
||||
interaction_required: 'Additional user interaction is required.',
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { user } = useAuth()
|
||||
@@ -18,7 +33,8 @@ onMounted(async () => {
|
||||
const errorParam = params.get('error')
|
||||
|
||||
if (errorParam) {
|
||||
error.value = params.get('error_description') ?? 'OIDC provider returned an error'
|
||||
const description = params.get('error_description')
|
||||
error.value = PROVIDER_ERROR_DESCRIPTIONS[errorParam] ?? description ?? 'Your identity provider returned an error. Please try again.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -28,15 +44,35 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const data: AuthResponse = await exchangeCode(code, state)
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
const data: OidcCallbackResponse = await exchangeCode(code, state)
|
||||
|
||||
const redirect = sessionStorage.getItem('oidc_redirect') ?? '/'
|
||||
sessionStorage.removeItem('oidc_redirect')
|
||||
router.replace(redirect)
|
||||
if (data.mode === 'login') {
|
||||
setAccessToken(data.accessToken)
|
||||
user.value = data.user
|
||||
const redirect = sessionStorage.getItem('oidc_redirect') ?? '/'
|
||||
sessionStorage.removeItem('oidc_redirect')
|
||||
router.replace(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.mode === 'link') {
|
||||
sessionStorage.removeItem('oidc_link_pending')
|
||||
router.replace('/settings/account')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.mode === 'preview') {
|
||||
sessionStorage.removeItem('oidc_preview_pending')
|
||||
sessionStorage.setItem('oidc_preview_claims', JSON.stringify(data.claims))
|
||||
router.replace('/settings/oidc')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'OIDC login failed'
|
||||
if (err instanceof OidcLoginError && err.errorCode && OIDC_ERROR_MESSAGES[err.errorCode]) {
|
||||
error.value = OIDC_ERROR_MESSAGES[err.errorCode]
|
||||
} else {
|
||||
error.value = err instanceof Error ? err.message : 'OIDC login failed'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -55,7 +91,7 @@ onMounted(async () => {
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<p class="text-sm text-destructive">{{ error }}</p>
|
||||
<RouterLink to="/login" class="text-sm text-primary hover:underline">Back to login</RouterLink>
|
||||
<RouterLink to="/login" class="text-sm text-primary hover:underline">Try again</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OidcPublicConfig } from '@projectx/types'
|
||||
import type { OidcCallbackResponse, OidcPublicConfig } from '@projectx/types'
|
||||
|
||||
async function generatePkce(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
||||
const array = new Uint8Array(32)
|
||||
@@ -28,6 +28,15 @@ function generateNonce(): string {
|
||||
.replace(/=/g, '')
|
||||
}
|
||||
|
||||
export class OidcLoginError extends Error {
|
||||
constructor(
|
||||
public readonly errorCode: string | undefined,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export function useOidc() {
|
||||
async function getPublicConfig(): Promise<OidcPublicConfig | null> {
|
||||
try {
|
||||
@@ -41,27 +50,28 @@ export function useOidc() {
|
||||
|
||||
async function initiateLogin(): Promise<void> {
|
||||
const config = await getPublicConfig()
|
||||
if (!config?.enabled || !config.issuerUri) {
|
||||
throw new Error('OIDC is not configured')
|
||||
if (!config?.enabled) {
|
||||
throw new OidcLoginError(undefined, 'OIDC is not configured')
|
||||
}
|
||||
|
||||
// Fetch discovery document on the client side
|
||||
let discRes: Response
|
||||
try {
|
||||
discRes = await fetch(`${config.issuerUri.replace(/\/$/, '')}/.well-known/openid-configuration`)
|
||||
} catch {
|
||||
throw new Error('Cannot reach OIDC provider - check the Issuer URI in settings')
|
||||
}
|
||||
if (!discRes.ok) throw new Error(`OIDC provider returned HTTP ${discRes.status} - check the Issuer URI in settings`)
|
||||
const disc = await discRes.json()
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generatePkce()
|
||||
const nonce = generateNonce()
|
||||
|
||||
// Get server-side state (CSRF protection)
|
||||
// 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' })
|
||||
if (!stateRes.ok) throw new Error('Failed to generate state')
|
||||
const { state } = await stateRes.json()
|
||||
if (!stateRes.ok) throw new OidcLoginError(undefined, 'Failed to generate state')
|
||||
const { state, authorizationEndpoint } = (await stateRes.json()) as { state: string; authorizationEndpoint: string }
|
||||
|
||||
if (!authorizationEndpoint) {
|
||||
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 }))
|
||||
@@ -79,12 +89,12 @@ export function useOidc() {
|
||||
nonce,
|
||||
})
|
||||
|
||||
window.location.href = `${disc.authorization_endpoint}?${params.toString()}`
|
||||
window.location.href = `${authorizationEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, state: string) {
|
||||
const pkceJson = sessionStorage.getItem(`oidc_pkce_${state}`)
|
||||
if (!pkceJson) throw new Error('No PKCE data found for this state')
|
||||
if (!pkceJson) throw new OidcLoginError(undefined, 'No PKCE data found for this state')
|
||||
|
||||
const { codeVerifier, nonce } = JSON.parse(pkceJson) as { codeVerifier: string; nonce: string; state: string }
|
||||
sessionStorage.removeItem(`oidc_pkce_${state}`)
|
||||
@@ -99,11 +109,11 @@ export function useOidc() {
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'OIDC login failed')
|
||||
const err = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
throw new OidcLoginError(err['errorCode'] as string | undefined, (err['message'] as string) ?? 'OIDC login failed')
|
||||
}
|
||||
|
||||
return res.json()
|
||||
return res.json() as Promise<OidcCallbackResponse>
|
||||
}
|
||||
|
||||
return { getPublicConfig, initiateLogin, exchangeCode }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Save, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
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'
|
||||
@@ -129,6 +129,86 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
interface OidcIdentity {
|
||||
oidcSubject: string | null
|
||||
oidcIssuer: string | null
|
||||
}
|
||||
|
||||
const oidcIdentity = ref<OidcIdentity | null>(null)
|
||||
const oidcIdentityLoading = ref(false)
|
||||
const unlinkPassword = ref('')
|
||||
const unlinkDialogOpen = ref(false)
|
||||
const unlinking = ref(false)
|
||||
const linkingOidc = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
oidcIdentityLoading.value = true
|
||||
try {
|
||||
const res = await api('/api/auth/oidc/identity')
|
||||
if (res.ok) {
|
||||
oidcIdentity.value = await res.json()
|
||||
}
|
||||
} finally {
|
||||
oidcIdentityLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function initiateOidcLink() {
|
||||
linkingOidc.value = true
|
||||
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 params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: '',
|
||||
redirect_uri: `${window.location.origin}/oauth2-callback`,
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce: crypto.randomUUID(),
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUnlink() {
|
||||
if (!unlinkPassword.value) return
|
||||
unlinking.value = true
|
||||
try {
|
||||
const res = await api('/api/auth/oidc/identity', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: unlinkPassword.value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
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 }
|
||||
unlinkDialogOpen.value = false
|
||||
unlinkPassword.value = ''
|
||||
toast.success('OIDC identity unlinked')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to unlink identity')
|
||||
} finally {
|
||||
unlinking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openUnlinkDialog() {
|
||||
unlinkPassword.value = ''
|
||||
unlinkDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeUnlinkDialog() {
|
||||
unlinkDialogOpen.value = false
|
||||
unlinkPassword.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -261,6 +341,42 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OIDC Identity section -->
|
||||
<div v-if="!oidcIdentityLoading" class="mt-4">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="removeAvatarConfirmOpen"
|
||||
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
|
||||
@@ -286,4 +402,34 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlink OIDC identity confirmation dialog -->
|
||||
<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>
|
||||
<input
|
||||
v-model="unlinkPassword"
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
autocomplete="current-password"
|
||||
class="input-field mt-3 w-full"
|
||||
/>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<button class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors" @click="closeUnlinkDialog">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
:disabled="unlinking || !unlinkPassword"
|
||||
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
@click="confirmUnlink"
|
||||
>
|
||||
{{ unlinking ? 'Unlinking...' : 'Unlink' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Permission, PERMISSION_LABELS } from '@projectx/types'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
@@ -12,15 +14,45 @@ interface OidcConfig {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
scopes: string
|
||||
iconUrl?: string
|
||||
claimMapping: { username: string; name: string; email: string; groups: string }
|
||||
autoProvision: { enabled: boolean; allowLocalLinking: boolean; defaultPermissionNames: string[] }
|
||||
}
|
||||
|
||||
interface OidcTestResult {
|
||||
success: boolean
|
||||
issuer?: string
|
||||
authorizationEndpoint?: string
|
||||
tokenEndpoint?: string
|
||||
userinfoEndpoint?: string
|
||||
jwksUri?: string
|
||||
supportedScopes?: string[]
|
||||
codeChallengeMethodsSupported?: string[]
|
||||
backchannelLogoutSupported?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface GroupMapping {
|
||||
id: number
|
||||
oidcGroupClaim: string
|
||||
permissionName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const ALL_PERMISSIONS = Object.values(Permission)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const testResult = ref<{ success: boolean; message: string } | null>(null)
|
||||
const testResult = ref<OidcTestResult | null>(null)
|
||||
const showTestDetails = ref(false)
|
||||
const previewing = ref(false)
|
||||
const previewClaims = ref<{ raw: Record<string, unknown>; mapped: Record<string, unknown> } | null>(null)
|
||||
|
||||
const groupMappings = ref<GroupMapping[]>([])
|
||||
const newMapping = reactive({ oidcGroupClaim: '', permissionName: ALL_PERMISSIONS[0] })
|
||||
const addingMapping = ref(false)
|
||||
|
||||
const form = reactive<OidcConfig>({
|
||||
enabled: false,
|
||||
@@ -29,18 +61,34 @@ const form = reactive<OidcConfig>({
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
scopes: 'openid profile email',
|
||||
iconUrl: '',
|
||||
claimMapping: { username: 'preferred_username', name: 'name', email: 'email', groups: 'groups' },
|
||||
autoProvision: { enabled: false, allowLocalLinking: true, defaultPermissionNames: [] },
|
||||
})
|
||||
|
||||
const defaultPermissionSet = computed(() => new Set(form.autoProvision.defaultPermissionNames))
|
||||
|
||||
onMounted(async () => {
|
||||
const stored = sessionStorage.getItem('oidc_preview_claims')
|
||||
if (stored) {
|
||||
try {
|
||||
previewClaims.value = JSON.parse(stored)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sessionStorage.removeItem('oidc_preview_claims')
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/oidc')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
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
|
||||
}
|
||||
@@ -73,21 +121,90 @@ async function save() {
|
||||
|
||||
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 = await res.json()
|
||||
if (data.success) {
|
||||
testResult.value = { success: true, message: `Connected - issuer: ${data.issuer}` }
|
||||
} else {
|
||||
testResult.value = { success: false, message: data.error ?? 'Connection failed' }
|
||||
}
|
||||
const data: OidcTestResult = await res.json()
|
||||
testResult.value = data
|
||||
} catch {
|
||||
testResult.value = { success: false, message: 'Request failed' }
|
||||
testResult.value = { success: false, error: 'Request failed' }
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewOidcClaims() {
|
||||
previewing.value = true
|
||||
previewClaims.value = null
|
||||
try {
|
||||
const stateRes = await api('/api/auth/oidc/preview-state', { method: 'POST' })
|
||||
if (!stateRes.ok) throw new Error('Failed to generate preview state')
|
||||
const { state, authorizationEndpoint } = await stateRes.json()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: form.clientId,
|
||||
redirect_uri: `${window.location.origin}/oauth2-callback`,
|
||||
scope: form.scopes,
|
||||
state,
|
||||
nonce: crypto.randomUUID(),
|
||||
})
|
||||
sessionStorage.setItem('oidc_preview_pending', '1')
|
||||
window.location.href = `${authorizationEndpoint}?${params.toString()}`
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to start preview')
|
||||
previewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTestDetails() {
|
||||
showTestDetails.value = !showTestDetails.value
|
||||
}
|
||||
|
||||
function toggleDefaultPermission(permission: Permission) {
|
||||
const idx = form.autoProvision.defaultPermissionNames.indexOf(permission)
|
||||
if (idx === -1) {
|
||||
form.autoProvision.defaultPermissionNames.push(permission)
|
||||
} else {
|
||||
form.autoProvision.defaultPermissionNames.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function addGroupMapping() {
|
||||
if (!newMapping.oidcGroupClaim.trim()) return
|
||||
addingMapping.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/oidc/group-mappings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ oidcGroupClaim: newMapping.oidcGroupClaim.trim(), permissionName: newMapping.permissionName }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to add mapping')
|
||||
}
|
||||
const created: GroupMapping = await res.json()
|
||||
groupMappings.value.push(created)
|
||||
newMapping.oidcGroupClaim = ''
|
||||
toast.success('Group mapping added')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add mapping')
|
||||
} finally {
|
||||
addingMapping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroupMapping(id: number) {
|
||||
try {
|
||||
const res = await api(`/api/v1/app-settings/oidc/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')
|
||||
} catch {
|
||||
toast.error('Failed to remove group mapping')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -129,6 +246,16 @@ async function testConnection() {
|
||||
</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>
|
||||
@@ -148,12 +275,30 @@ async function testConnection() {
|
||||
</div>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="w-full text-xs px-3 py-1.5 rounded-md border md:w-auto"
|
||||
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'
|
||||
"
|
||||
>
|
||||
{{ testResult.message }}
|
||||
<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>
|
||||
@@ -180,7 +325,26 @@ async function testConnection() {
|
||||
|
||||
<!-- Claim mapping -->
|
||||
<div>
|
||||
<p class="settings-group-label">Claim Mapping</p>
|
||||
<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>
|
||||
@@ -219,6 +383,72 @@ async function testConnection() {
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ export enum AuditAction {
|
||||
AuthPasswordAdminReset = "auth.password.admin_reset",
|
||||
AuthSessionRevoke = "auth.session.revoke",
|
||||
|
||||
OidcLogin = "auth.oidc.login",
|
||||
OidcUserProvisioned = "auth.oidc.user_provisioned",
|
||||
OidcIdentityLinked = "auth.oidc.identity_linked",
|
||||
OidcIdentityUnlinked = "auth.oidc.identity_unlinked",
|
||||
|
||||
UserCreate = "user.create",
|
||||
UserUpdate = "user.update",
|
||||
UserSelfUpdate = "user.self_update",
|
||||
@@ -98,6 +103,7 @@ export enum AuditResource {
|
||||
EmailTemplate = "email_template",
|
||||
EmailRecipient = "email_recipient",
|
||||
EmailRecipientGroup = "email_recipient_group",
|
||||
OidcIdentity = "oidc_identity",
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
export enum OidcErrorCode {
|
||||
STATE_EXPIRED = 'oidc_state_expired',
|
||||
TOKEN_EXCHANGE_FAILED = 'oidc_token_exchange_failed',
|
||||
USER_NOT_PROVISIONED = 'oidc_user_not_provisioned',
|
||||
USER_INACTIVE = 'oidc_user_inactive',
|
||||
PROVIDER_ERROR = 'oidc_provider_error',
|
||||
}
|
||||
|
||||
export const ProvisioningMethod = {
|
||||
Local: 'local',
|
||||
Manual: 'manual',
|
||||
Oidc: 'oidc',
|
||||
} as const;
|
||||
|
||||
export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod];
|
||||
|
||||
export interface UserSettings {
|
||||
syncReaderPreferences?: boolean;
|
||||
statisticsConfig?: import("./statistics").StatisticsSettings;
|
||||
@@ -13,7 +29,7 @@ export interface AuthUser {
|
||||
isDefaultPassword: boolean;
|
||||
settings: UserSettings;
|
||||
avatarUrl?: string | null;
|
||||
provisioningMethod: "local" | "oidc";
|
||||
provisioningMethod: ProvisioningMethod;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@@ -23,8 +39,54 @@ export interface OidcPublicConfig {
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
scopes: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
export interface OidcClaimMapping {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string;
|
||||
}
|
||||
|
||||
export interface OidcAutoProvision {
|
||||
enabled: boolean;
|
||||
allowLocalLinking: boolean;
|
||||
defaultPermissionNames: string[];
|
||||
}
|
||||
|
||||
export interface OidcBaseConfig {
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
scopes: string;
|
||||
iconUrl?: string;
|
||||
claimMapping: OidcClaimMapping;
|
||||
autoProvision: OidcAutoProvision;
|
||||
}
|
||||
|
||||
export interface OidcCallbackResult {
|
||||
mode: 'login';
|
||||
accessToken: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export interface OidcLinkResult {
|
||||
mode: 'link';
|
||||
linked: true;
|
||||
}
|
||||
|
||||
export interface OidcPreviewResult {
|
||||
mode: 'preview';
|
||||
claims: {
|
||||
raw: Record<string, unknown>;
|
||||
mapped: { username: string; name: string; email?: string; groups: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
export type OidcCallbackResponse = OidcCallbackResult | OidcLinkResult | OidcPreviewResult;
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
user: AuthUser;
|
||||
|
||||
@@ -25,6 +25,13 @@ APP_DATA_PATH=../local/data
|
||||
# OIDC (optional — configure via admin settings UI; these are just for reference)
|
||||
# OIDC config is stored in the database (app_settings table, key: oidc_config)
|
||||
|
||||
# OIDC runtime tuning (optional — uncomment to override defaults)
|
||||
# OIDC_STATE_TTL_SECS=600
|
||||
# OIDC_DISCOVERY_CACHE_TTL_SECS=3600
|
||||
# OIDC_JWKS_CACHE_TTL_SECS=3600
|
||||
# OIDC_CLOCK_TOLERANCE_SECS=30
|
||||
# OIDC_TOKEN_EXCHANGE_TIMEOUT_MS=10000
|
||||
|
||||
# App URL (used for building password reset links in emails)
|
||||
APP_URL=http://localhost:5173
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, storageConfig } from './config/config';
|
||||
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, oidcRuntimeConfig, storageConfig } from './config/config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { loggerConfig } from './common/logger.config';
|
||||
import { CommonModule } from './common/common.module';
|
||||
@@ -59,7 +59,7 @@ import { MigrationModule } from './modules/migration/migration.module';
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, fileWriteConfig, emailConfig, migrationConfig],
|
||||
load: [appConfig, dbConfig, authConfig, storageConfig, fileWriteConfig, emailConfig, migrationConfig, oidcRuntimeConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRoot({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthorAutoEnrichmentConfig } from '@projectx/types';
|
||||
import type { AuthorAutoEnrichmentConfig, OidcBaseConfig } from '@projectx/types';
|
||||
|
||||
export const APP_SETTING_KEYS = {
|
||||
ALLOW_REGISTRATION: 'allow_registration',
|
||||
@@ -24,24 +24,8 @@ export const APP_SETTING_KEYS = {
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||
|
||||
export interface OidcFullConfig {
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
export interface OidcFullConfig extends OidcBaseConfig {
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
claimMapping: {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string;
|
||||
};
|
||||
autoProvision: {
|
||||
enabled: boolean;
|
||||
allowLocalLinking: boolean;
|
||||
defaultPermissionNames: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_OIDC_CONFIG: OidcFullConfig = {
|
||||
|
||||
@@ -33,6 +33,14 @@ export const migrationConfig = registerAs('migration', () => ({
|
||||
encryptionKey: process.env.MIGRATION_ENCRYPTION_KEY ?? '',
|
||||
}));
|
||||
|
||||
export const oidcRuntimeConfig = registerAs('oidcRuntime', () => ({
|
||||
stateTtlMs: parsePositiveInteger(process.env.OIDC_STATE_TTL_SECS, 300) * 1000,
|
||||
discoveryCacheTtlMs: parsePositiveInteger(process.env.OIDC_DISCOVERY_CACHE_TTL_SECS, 3600) * 1000,
|
||||
jwksCacheTtlMs: parsePositiveInteger(process.env.OIDC_JWKS_CACHE_TTL_SECS, 21600) * 1000,
|
||||
clockToleranceSecs: parsePositiveInteger(process.env.OIDC_CLOCK_TOLERANCE_SECS, 30),
|
||||
tokenExchangeTimeoutMs: parsePositiveInteger(process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS, 10_000),
|
||||
}));
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
|
||||
@@ -573,11 +573,23 @@ CREATE TABLE "oidc_sessions" (
|
||||
"oidc_issuer" text NOT NULL,
|
||||
"oidc_session_id" text,
|
||||
"id_token_hint" text,
|
||||
"idp_refresh_token" text,
|
||||
"revoked" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_states" (
|
||||
"state" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"meta" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oidc_used_jtis" (
|
||||
"jti" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "opds_users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
@@ -983,6 +995,8 @@ CREATE INDEX "oidc_sessions_user_active_idx" ON "oidc_sessions" USING btree ("us
|
||||
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_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
|
||||
CREATE INDEX "kobo_devices_user_id_idx" ON "kobo_devices" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "kobo_snapshot_books_snapshot_synced_book_idx" ON "kobo_snapshot_books" USING btree ("snapshot_id","synced","book_id");--> statement-breakpoint
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "7d462c3d-9524-40b8-95e1-afda1b364a68",
|
||||
"id": "373242c5-f28a-4dd1-a03f-e6b0abec26c9",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -5348,6 +5348,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"idp_refresh_token": {
|
||||
"name": "idp_refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked": {
|
||||
"name": "revoked",
|
||||
"type": "boolean",
|
||||
@@ -5455,6 +5461,94 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_states": {
|
||||
"name": "oidc_states",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"meta": {
|
||||
"name": "meta",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_states_expires_at_idx": {
|
||||
"name": "oidc_states_expires_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.oidc_used_jtis": {
|
||||
"name": "oidc_used_jtis",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"jti": {
|
||||
"name": "jti",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"oidc_used_jtis_expires_at_idx": {
|
||||
"name": "oidc_used_jtis_expires_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.opds_users": {
|
||||
"name": "opds_users",
|
||||
"schema": "",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1776080409147,
|
||||
"when": 1776150932210,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -3,6 +3,25 @@ import { boolean, index, integer, pgTable, serial, text, timestamp, varchar } fr
|
||||
|
||||
import { users } from './auth';
|
||||
|
||||
export const oidcStates = pgTable(
|
||||
'oidc_states',
|
||||
{
|
||||
state: text('state').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
meta: text('meta'),
|
||||
},
|
||||
(t) => [index('oidc_states_expires_at_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const oidcUsedJtis = pgTable(
|
||||
'oidc_used_jtis',
|
||||
{
|
||||
jti: text('jti').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(t) => [index('oidc_used_jtis_expires_at_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const oidcSessions = pgTable(
|
||||
'oidc_sessions',
|
||||
{
|
||||
@@ -14,6 +33,7 @@ export const oidcSessions = pgTable(
|
||||
oidcIssuer: text('oidc_issuer').notNull(),
|
||||
oidcSessionId: text('oidc_session_id'),
|
||||
idTokenHint: text('id_token_hint'),
|
||||
idpRefreshToken: text('idp_refresh_token'),
|
||||
revoked: boolean('revoked').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
|
||||
function makeService(): jest.Mocked<AppSettingsService> {
|
||||
return {
|
||||
@@ -25,6 +26,15 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
||||
} as unknown as jest.Mocked<AppSettingsService>;
|
||||
}
|
||||
|
||||
function makeGroupMappingService(): jest.Mocked<OidcGroupMappingAdminService> {
|
||||
return {
|
||||
listMappings: vi.fn().mockResolvedValue([]),
|
||||
createMapping: vi.fn(),
|
||||
updateMapping: vi.fn(),
|
||||
deleteMapping: vi.fn(),
|
||||
} as unknown as jest.Mocked<OidcGroupMappingAdminService>;
|
||||
}
|
||||
|
||||
describe('AppSettingsController', () => {
|
||||
let controller: AppSettingsController;
|
||||
let service: ReturnType<typeof makeService>;
|
||||
@@ -33,7 +43,10 @@ describe('AppSettingsController', () => {
|
||||
service = makeService();
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [{ provide: AppSettingsService, useValue: service }],
|
||||
providers: [
|
||||
{ provide: AppSettingsService, useValue: service },
|
||||
{ provide: OidcGroupMappingAdminService, useValue: makeGroupMappingService() },
|
||||
],
|
||||
}).compile();
|
||||
controller = module.get(AppSettingsController);
|
||||
});
|
||||
@@ -93,6 +106,7 @@ describe('AppSettingsController', () => {
|
||||
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: [] },
|
||||
});
|
||||
@@ -101,6 +115,7 @@ describe('AppSettingsController', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
|
||||
import { Permission, AuditAction, AuditResource } from '@projectx/types';
|
||||
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 { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
||||
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
|
||||
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
|
||||
import { CreateGroupMappingDto } from './dto/create-group-mapping.dto';
|
||||
import { UpdateGroupMappingDto } from './dto/update-group-mapping.dto';
|
||||
|
||||
@Controller('app-settings')
|
||||
@RequirePermission(Permission.ManageAppSettings)
|
||||
export class AppSettingsController {
|
||||
constructor(private readonly appSettingsService: AppSettingsService) {}
|
||||
constructor(
|
||||
private readonly appSettingsService: AppSettingsService,
|
||||
private readonly groupMappingAdmin: OidcGroupMappingAdminService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
listSettings() {
|
||||
@@ -66,6 +72,7 @@ export class AppSettingsController {
|
||||
issuerUri: config.issuerUri,
|
||||
clientId: config.clientId,
|
||||
scopes: config.scopes,
|
||||
iconUrl: config.iconUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,4 +93,27 @@ export class AppSettingsController {
|
||||
testOidcConnection(@Query('issuerUri') issuerUri?: string) {
|
||||
return this.appSettingsService.testOidcConnection(issuerUri);
|
||||
}
|
||||
|
||||
@Get('oidc/group-mappings')
|
||||
listGroupMappings() {
|
||||
return this.groupMappingAdmin.listMappings();
|
||||
}
|
||||
|
||||
@Post('oidc/group-mappings')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
createGroupMapping(@Body() dto: CreateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.createMapping(dto.oidcGroupClaim, dto.permissionName);
|
||||
}
|
||||
|
||||
@Put('oidc/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateGroupMapping(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateGroupMappingDto) {
|
||||
return this.groupMappingAdmin.updateMapping(id, dto.permissionName);
|
||||
}
|
||||
|
||||
@Delete('oidc/group-mappings/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteGroupMapping(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.groupMappingAdmin.deleteMapping(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Module } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
controllers: [AppSettingsController],
|
||||
providers: [AppSettingsRepository, AppSettingsService],
|
||||
providers: [AppSettingsRepository, AppSettingsService, OidcGroupMappingAdminService],
|
||||
exports: [AppSettingsService],
|
||||
})
|
||||
export class AppSettingsModule {}
|
||||
|
||||
@@ -104,7 +104,19 @@ export class AppSettingsService {
|
||||
return merged;
|
||||
}
|
||||
|
||||
async testOidcConnection(issuerUri?: string): Promise<{ success: boolean; issuer?: string; authorizationEndpoint?: string; error?: string }> {
|
||||
async testOidcConnection(issuerUri?: string): Promise<{
|
||||
success: boolean;
|
||||
issuer?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
userinfoEndpoint?: string;
|
||||
jwksUri?: string;
|
||||
supportedScopes?: string[];
|
||||
supportedGrantTypes?: string[];
|
||||
codeChallengeMethodsSupported?: string[];
|
||||
backchannelLogoutSupported?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const uri = issuerUri || (await this.getOidcConfig()).issuerUri;
|
||||
if (!uri) {
|
||||
throw new BadRequestException('Issuer URI is not configured');
|
||||
@@ -128,7 +140,18 @@ export class AppSettingsService {
|
||||
throw new BadRequestException('Provider returned an invalid discovery document');
|
||||
}
|
||||
this.logger.log(`[app-settings.oidc_test] [end] issuerUri=${uri} durationMs=${Date.now() - start} - OIDC connection test succeeded`);
|
||||
return { success: true, issuer: json.issuer, authorizationEndpoint: json.authorization_endpoint };
|
||||
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';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsIn, IsString, MaxLength } from 'class-validator';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
export class CreateGroupMappingDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
oidcGroupClaim: string;
|
||||
|
||||
@IsIn(Object.values(Permission))
|
||||
permissionName: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsIn } from 'class-validator';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
export class UpdateGroupMappingDto {
|
||||
@IsIn(Object.values(Permission))
|
||||
permissionName: string;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator';
|
||||
|
||||
class ClaimMappingDto {
|
||||
@IsString()
|
||||
@@ -43,7 +43,7 @@ export class UpdateOidcConfigDto {
|
||||
providerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
issuerUri?: string;
|
||||
|
||||
@@ -62,6 +62,11 @@ export class UpdateOidcConfigDto {
|
||||
@MaxLength(500)
|
||||
scopes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, protocols: ['http', 'https'] })
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ClaimMappingDto)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { OidcGroupMappingAdminService } from './oidc-group-mapping-admin.service';
|
||||
|
||||
function makeDb() {
|
||||
return {
|
||||
query: {
|
||||
oidcGroupMappings: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OidcGroupMappingAdminService', () => {
|
||||
let service: OidcGroupMappingAdminService;
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new OidcGroupMappingAdminService(db as never);
|
||||
});
|
||||
|
||||
describe('listMappings', () => {
|
||||
it('returns all mappings ordered by oidcGroupClaim', async () => {
|
||||
const rows = [{ id: 1, oidcGroupClaim: 'admins', permissionName: 'manage_users' }];
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue(rows);
|
||||
await expect(service.listMappings()).resolves.toEqual(rows);
|
||||
});
|
||||
});
|
||||
|
||||
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' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
const result = await service.updateMapping(5, 'manage_users');
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(db.set).toHaveBeenCalledWith({ permissionName: 'manage_users' });
|
||||
expect(result).toEqual(row);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when row does not exist', async () => {
|
||||
db.returning.mockResolvedValue([]);
|
||||
await expect(service.updateMapping(99, 'manage_users')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMapping', () => {
|
||||
it('deletes the row when found', async () => {
|
||||
const row = { id: 3, oidcGroupClaim: 'admins', permissionName: 'manage_users' };
|
||||
db.returning.mockResolvedValue([row]);
|
||||
await expect(service.deleteMapping(3)).resolves.toBeUndefined();
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when row does not exist', async () => {
|
||||
db.returning.mockResolvedValue([]);
|
||||
await expect(service.deleteMapping(99)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { 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 OidcGroupMappingAdminService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
listMappings() {
|
||||
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`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async deleteMapping(id: number) {
|
||||
const [row] = await this.db.delete(schema.oidcGroupMappings).where(eq(schema.oidcGroupMappings.id, id)).returning();
|
||||
if (!row) throw new NotFoundException(`Group mapping ${id} not found`);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/auth/auth.service.ts',
|
||||
'src/modules/auth/oidc/backchannel-logout.service.ts',
|
||||
'src/modules/auth/oidc/oidc-group-mapping.service.ts',
|
||||
'src/modules/auth/oidc/oidc-state.service.ts',
|
||||
'src/modules/authors/author-enrichment-config.service.ts',
|
||||
'src/modules/book/book-query-builder.service.ts',
|
||||
'src/modules/book-metadata-fetch/book-metadata-fetch-config.service.ts',
|
||||
@@ -66,6 +67,8 @@ describe('Architecture boundaries', () => {
|
||||
'src/modules/book-bucket/book-bucket.service.ts',
|
||||
'src/modules/upload/upload-processor.service.ts',
|
||||
'src/modules/upload/upload.service.ts',
|
||||
'src/modules/app-settings/oidc-group-mapping-admin.service.ts',
|
||||
'src/modules/auth/oidc/oidc-cleanup.service.ts',
|
||||
].sort();
|
||||
|
||||
const allFiles = await listFilesRecursive(modulesRoot);
|
||||
|
||||
@@ -19,8 +19,12 @@ function makeController() {
|
||||
};
|
||||
const oidcService = {
|
||||
generateState: vi.fn(),
|
||||
generateLinkState: vi.fn(),
|
||||
generatePreviewState: vi.fn(),
|
||||
handleCallback: vi.fn(),
|
||||
handleBackchannelLogout: vi.fn(),
|
||||
getLinkedIdentity: vi.fn(),
|
||||
unlinkIdentity: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new AuthController(authService as never, oidcService as never);
|
||||
@@ -63,16 +67,28 @@ describe('AuthController', () => {
|
||||
it('delegates oidc endpoints to OidcService', async () => {
|
||||
const { controller, oidcService } = makeController();
|
||||
const reply = {} as never;
|
||||
oidcService.generateState.mockReturnValue('state-123');
|
||||
const stateResult = { state: 'state-123', authorizationEndpoint: 'https://idp.example.com/auth' };
|
||||
oidcService.generateState.mockResolvedValue(stateResult);
|
||||
oidcService.generateLinkState.mockResolvedValue(stateResult);
|
||||
oidcService.generatePreviewState.mockResolvedValue(stateResult);
|
||||
oidcService.getLinkedIdentity.mockResolvedValue({ oidcSubject: 'sub-1', oidcIssuer: 'https://idp.example.com' });
|
||||
|
||||
expect(controller.oidcGenerateState()).toEqual({ state: 'state-123' });
|
||||
await expect(controller.oidcGenerateState()).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!' });
|
||||
|
||||
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!');
|
||||
});
|
||||
|
||||
it('defines throttling metadata for sensitive public endpoints', () => {
|
||||
@@ -100,5 +116,15 @@ describe('AuthController', () => {
|
||||
expect(forgotTtl).toBe(3_600_000);
|
||||
expect(resetLimit).toBe(5);
|
||||
expect(resetTtl).toBe(60_000);
|
||||
|
||||
const oidcStateLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcGenerateState) as number;
|
||||
const oidcStateTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcGenerateState) as number;
|
||||
const oidcCallbackLimit = Reflect.getMetadata(limitKey, AuthController.prototype.oidcCallback) as number;
|
||||
const oidcCallbackTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.oidcCallback) as number;
|
||||
|
||||
expect(oidcStateLimit).toBe(10);
|
||||
expect(oidcStateTtl).toBe(60_000);
|
||||
expect(oidcCallbackLimit).toBe(5);
|
||||
expect(oidcCallbackTtl).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { OidcCallbackDto } from './dto/oidc-callback.dto';
|
||||
import { OidcUnlinkDto } from './dto/oidc-unlink.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { SetupDto } from './dto/setup.dto';
|
||||
@@ -123,13 +124,15 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 10, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateState() {
|
||||
return { state: this.oidcService.generateState() };
|
||||
return this.oidcService.generateState();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/callback')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcCallback(@Body() dto: OidcCallbackDto, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
@@ -144,4 +147,29 @@ export class AuthController {
|
||||
const logoutToken = body?.['logout_token'] ?? '';
|
||||
return this.oidcService.handleBackchannelLogout(logoutToken);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/link-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGenerateLinkState(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.generateLinkState(user.id);
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
|
||||
@Post('oidc/preview-state')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
oidcGeneratePreviewState() {
|
||||
return this.oidcService.generatePreviewState();
|
||||
}
|
||||
|
||||
@Get('oidc/identity')
|
||||
oidcGetIdentity(@CurrentUser() user: RequestUser) {
|
||||
return this.oidcService.getLinkedIdentity(user.id);
|
||||
}
|
||||
|
||||
@Delete('oidc/identity')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Body() dto: OidcUnlinkDto) {
|
||||
return this.oidcService.unlinkIdentity(user.id, dto.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { BackchannelLogoutService } from './oidc/backchannel-logout.service';
|
||||
import { OidcClaimExtractorService } from './oidc/oidc-claim-extractor.service';
|
||||
import { OidcCleanupService } from './oidc/oidc-cleanup.service';
|
||||
import { OidcDiscoveryService } from './oidc/oidc-discovery.service';
|
||||
import { OidcGroupMappingService } from './oidc/oidc-group-mapping.service';
|
||||
import { OidcService } from './oidc/oidc.service';
|
||||
@@ -51,6 +52,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
|
||||
OidcSessionRepository,
|
||||
OidcGroupMappingService,
|
||||
BackchannelLogoutService,
|
||||
OidcCleanupService,
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class OidcUnlinkDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password: string;
|
||||
}
|
||||
@@ -13,12 +13,26 @@ const DISCOVERY_DOC = {
|
||||
backchannelLogoutSupported: true,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
const db = {
|
||||
function makeDb() {
|
||||
return {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ jti: 'jti-1' }]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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' }),
|
||||
};
|
||||
@@ -103,18 +117,6 @@ describe('BackchannelLogoutService', () => {
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents JTI replay — ignores second call with same jti', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'replay-jti', exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('token-1');
|
||||
await service.handleLogout('token-2');
|
||||
|
||||
// incrementTokenVersion should only be called once (second call is a replay)
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('proceeds normally when jti is absent', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1' });
|
||||
@@ -123,4 +125,76 @@ describe('BackchannelLogoutService', () => {
|
||||
await service.handleLogout('token-no-jti');
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
describe('JTI replay prevention (DB-backed)', () => {
|
||||
it('rejects second call when DB insert returns empty (conflict = already used)', async () => {
|
||||
const jtiInsertResult: { jti: string }[] = [];
|
||||
const db = makeDb();
|
||||
db.insert.mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue(jtiInsertResult),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService(db);
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({
|
||||
sub: 'u1',
|
||||
jti: 'replay-jti',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('token-1');
|
||||
|
||||
// incrementTokenVersion should NOT be called because insert returned empty (replay)
|
||||
expect(userService.incrementTokenVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes successfully when DB insert returns the new row (first use)', async () => {
|
||||
const { service, tokenValidator, sessionRepo, userService } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({
|
||||
sub: 'u1',
|
||||
jti: 'fresh-jti',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 10 }]);
|
||||
|
||||
await service.handleLogout('fresh-token');
|
||||
|
||||
expect(userService.incrementTokenVersion).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('stores JTI with expiry derived from exp claim', async () => {
|
||||
const valuesMock = vi.fn().mockReturnValue({
|
||||
onConflictDoNothing: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ jti: 'jti-exp' }]),
|
||||
}),
|
||||
});
|
||||
const db = makeDb();
|
||||
db.insert.mockReturnValue({ values: valuesMock });
|
||||
|
||||
const { service, tokenValidator, sessionRepo } = makeService(db);
|
||||
const exp = Math.floor(Date.now() / 1000) + 7200;
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'jti-exp', exp });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 5 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
|
||||
const [[{ jti, expiresAt }]] = valuesMock.mock.calls;
|
||||
expect(jti).toBe('jti-exp');
|
||||
expect(expiresAt.getTime()).toBeCloseTo(exp * 1000, -3);
|
||||
});
|
||||
|
||||
it('prunes expired JTIs from DB after each logout', async () => {
|
||||
const { service, tokenValidator, sessionRepo, db } = makeService();
|
||||
tokenValidator.validateLogoutToken.mockResolvedValue({ sub: 'u1', jti: 'prune-jti' });
|
||||
sessionRepo.findActiveBySubjectAndIssuer.mockResolvedValue([{ userId: 3 }]);
|
||||
|
||||
await service.handleLogout('token');
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { and, eq, isNull, lt } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -15,8 +15,6 @@ type Db = NodePgDatabase<typeof schema>;
|
||||
@Injectable()
|
||||
export class BackchannelLogoutService {
|
||||
private readonly logger = new Logger(BackchannelLogoutService.name);
|
||||
// JTI replay prevention: jti -> expiry timestamp
|
||||
private readonly usedJtis = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@@ -39,24 +37,23 @@ export class BackchannelLogoutService {
|
||||
jwksUri: discovery.jwksUri,
|
||||
});
|
||||
|
||||
// Replay prevention
|
||||
// Replay prevention via DB — atomic insert, conflict = already used
|
||||
if (claims.jti) {
|
||||
const jti = typeof claims.jti === 'string' ? claims.jti : undefined;
|
||||
if (jti && this.usedJtis.has(jti)) {
|
||||
this.logger.warn(
|
||||
'[auth.oidc_backchannel_logout] [fail] errorClass=UnauthorizedException error="logout token jti replay" - backchannel logout rejected',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exp = claims.exp ? claims.exp * 1000 : Date.now() + 3_600_000;
|
||||
if (jti) {
|
||||
this.usedJtis.set(jti, exp);
|
||||
}
|
||||
const expiresAt = claims.exp ? new Date(claims.exp * 1000) : new Date(Date.now() + 3_600_000);
|
||||
|
||||
// Prune expired JTIs
|
||||
const now = Date.now();
|
||||
for (const [jti, expiry] of this.usedJtis) {
|
||||
if (expiry < now) this.usedJtis.delete(jti);
|
||||
const inserted = await this.db.insert(schema.oidcUsedJtis).values({ jti, expiresAt }).onConflictDoNothing().returning();
|
||||
|
||||
if (inserted.length === 0) {
|
||||
this.logger.warn(
|
||||
'[auth.oidc_backchannel_logout] [fail] errorClass=UnauthorizedException error="logout token jti replay" - backchannel logout rejected',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune expired JTIs
|
||||
await this.db.delete(schema.oidcUsedJtis).where(lt(schema.oidcUsedJtis.expiresAt, new Date()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ExtractedClaims {
|
||||
subject: string;
|
||||
@@ -18,15 +18,36 @@ export interface ClaimMapping {
|
||||
|
||||
@Injectable()
|
||||
export class OidcClaimExtractorService {
|
||||
private readonly logger = new Logger(OidcClaimExtractorService.name);
|
||||
|
||||
extract(idTokenClaims: Record<string, unknown>, userInfoClaims: Record<string, unknown>, mapping: ClaimMapping): ExtractedClaims {
|
||||
// userInfo claims take precedence over ID token claims
|
||||
const merged = { ...idTokenClaims, ...userInfoClaims };
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const subject = str(merged.sub);
|
||||
const username = str(merged[mapping.username]) || str(merged.email) || str(merged.sub);
|
||||
const name = str(merged[mapping.name]) || str(merged.name) || username;
|
||||
|
||||
const rawUsername = str(merged[mapping.username]);
|
||||
const username = rawUsername || str(merged.email) || str(merged.sub);
|
||||
if (!rawUsername && username) {
|
||||
this.logger.warn(
|
||||
`[auth.oidc_claim_extract] configured username claim "${mapping.username}" missing; fell back to "${!str(merged.email) ? 'sub' : 'email'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawName = str(merged[mapping.name]);
|
||||
const name = rawName || str(merged.name) || username;
|
||||
if (!rawName && name) {
|
||||
this.logger.warn(
|
||||
`[auth.oidc_claim_extract] configured name claim "${mapping.name}" missing; fell back to "${!str(merged.name) ? 'username' : 'name'}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const email = typeof merged[mapping.email] === 'string' ? (merged[mapping.email] as string) : undefined;
|
||||
if (!email && mapping.email) {
|
||||
this.logger.warn(`[auth.oidc_claim_extract] configured email claim "${mapping.email}" missing or not a string`);
|
||||
}
|
||||
|
||||
const avatarUrl = typeof merged.picture === 'string' ? merged.picture : undefined;
|
||||
|
||||
const rawGroups = merged[mapping.groups];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { OidcCleanupService } from './oidc-cleanup.service';
|
||||
|
||||
function makeDb() {
|
||||
const chain = {
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
};
|
||||
return {
|
||||
delete: vi.fn().mockReturnValue(chain),
|
||||
_chain: chain,
|
||||
};
|
||||
}
|
||||
|
||||
describe('OidcCleanupService', () => {
|
||||
let service: OidcCleanupService;
|
||||
let db: ReturnType<typeof makeDb>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = makeDb();
|
||||
service = new OidcCleanupService(db as never);
|
||||
});
|
||||
|
||||
it('deletes expired/revoked sessions, expired states, and expired JTIs in parallel', async () => {
|
||||
db._chain.returning
|
||||
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]) // sessions
|
||||
.mockResolvedValueOnce([{ state: 'abc' }]) // states
|
||||
.mockResolvedValueOnce([]); // jtis
|
||||
|
||||
await service.runCleanup();
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('logs deletion counts on success', async () => {
|
||||
db._chain.returning
|
||||
.mockResolvedValueOnce([{ id: 1 }])
|
||||
.mockResolvedValueOnce([{ state: 'xyz' }])
|
||||
.mockResolvedValueOnce([{ jti: 'jti-1' }, { jti: 'jti-2' }]);
|
||||
|
||||
const logSpy = vi.spyOn(service['logger'], 'log').mockImplementation(() => undefined);
|
||||
|
||||
await service.runCleanup();
|
||||
|
||||
const endLog = logSpy.mock.calls.find((args) => String(args[0]).includes('[end]'));
|
||||
expect(endLog).toBeTruthy();
|
||||
expect(String(endLog![0])).toMatch('deletedSessions=1');
|
||||
expect(String(endLog![0])).toMatch('deletedStates=1');
|
||||
expect(String(endLog![0])).toMatch('deletedJtis=2');
|
||||
});
|
||||
|
||||
it('logs error and does not throw when cleanup fails', async () => {
|
||||
db._chain.returning.mockRejectedValueOnce(new Error('DB connection lost'));
|
||||
|
||||
const errorSpy = vi.spyOn(service['logger'], 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.runCleanup()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringMatching('[fail]'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { lt, or, 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 OidcCleanupService {
|
||||
private readonly logger = new Logger(OidcCleanupService.name);
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async runCleanup(): Promise<void> {
|
||||
const start = Date.now();
|
||||
this.logger.log('[auth.oidc_cleanup] [start] - OIDC cleanup started');
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const [sessions, states, jtis] = await Promise.all([
|
||||
this.db
|
||||
.delete(schema.oidcSessions)
|
||||
.where(or(lt(schema.oidcSessions.expiresAt, now), eq(schema.oidcSessions.revoked, true)))
|
||||
.returning({ id: schema.oidcSessions.id }),
|
||||
this.db.delete(schema.oidcStates).where(lt(schema.oidcStates.expiresAt, now)).returning({ state: schema.oidcStates.state }),
|
||||
this.db.delete(schema.oidcUsedJtis).where(lt(schema.oidcUsedJtis.expiresAt, now)).returning({ jti: schema.oidcUsedJtis.jti }),
|
||||
]);
|
||||
|
||||
this.logger.log(
|
||||
`[auth.oidc_cleanup] [end] durationMs=${Date.now() - start} deletedSessions=${sessions.length} deletedStates=${states.length} deletedJtis=${jtis.length} - OIDC cleanup completed`,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`[auth.oidc_cleanup] [fail] durationMs=${Date.now() - start} errorClass=${errorClass} error="${errorMsg}" - OIDC cleanup failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
|
||||
vi.mock('../../../common/utils/ssrf.utils', () => ({
|
||||
ensureSafeUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { ensureSafeUrl } from '../../../common/utils/ssrf.utils';
|
||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
|
||||
const RAW_DOC = {
|
||||
@@ -12,15 +17,24 @@ const RAW_DOC = {
|
||||
backchannel_logout_supported: true,
|
||||
};
|
||||
|
||||
function makeService(nodeEnv = 'development') {
|
||||
const configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'app.nodeEnv') return nodeEnv;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
return new OidcDiscoveryService(configService as never);
|
||||
}
|
||||
|
||||
describe('OidcDiscoveryService', () => {
|
||||
let service: OidcDiscoveryService;
|
||||
let fetchMock: vi.Mock;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcDiscoveryService();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(ensureSafeUrl).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -37,6 +51,7 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('fetches and normalizes the discovery document', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
|
||||
const doc = await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(doc.issuer).toBe('https://idp.example.com');
|
||||
@@ -50,12 +65,14 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('strips trailing slash from issuerUri before fetching', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com/');
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://idp.example.com/.well-known/openid-configuration', expect.anything());
|
||||
});
|
||||
|
||||
it('returns cached result on second call within TTL', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -63,6 +80,7 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('re-fetches after TTL expires', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
vi.advanceTimersByTime(60 * 60 * 1000 + 1);
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
@@ -71,11 +89,13 @@ describe('OidcDiscoveryService', () => {
|
||||
|
||||
it('throws InternalServerErrorException on non-OK response', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('throws InternalServerErrorException on network error', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('network failure'));
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
@@ -87,9 +107,122 @@ describe('OidcDiscoveryService', () => {
|
||||
jwks_uri: 'https://idp.example.com/jwks',
|
||||
};
|
||||
mockFetchSuccess(minimalDoc as never);
|
||||
const service = makeService();
|
||||
const doc = await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(doc.userinfoEndpoint).toBeUndefined();
|
||||
expect(doc.endSessionEndpoint).toBeUndefined();
|
||||
expect(doc.backchannelLogoutSupported).toBe(false);
|
||||
});
|
||||
|
||||
it('uses redirect:error to prevent redirect-based SSRF', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ redirect: 'error' }));
|
||||
});
|
||||
|
||||
describe('P0-3: issuer validation', () => {
|
||||
it('throws when discovery doc issuer does not match requested URI', async () => {
|
||||
const mismatchedDoc = { ...RAW_DOC, issuer: 'https://attacker.example.com' };
|
||||
mockFetchSuccess(mismatchedDoc);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('accepts issuer with trailing slash on either side', async () => {
|
||||
const docWithSlash = { ...RAW_DOC, issuer: 'https://idp.example.com/' };
|
||||
mockFetchSuccess(docWithSlash);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com/')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts exact match between issuerUri and discovery doc issuer', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts issuer with sub-path (e.g. Keycloak realm)', async () => {
|
||||
const keycloakDoc = {
|
||||
...RAW_DOC,
|
||||
issuer: 'http://localhost:8080/realms/projectx',
|
||||
authorization_endpoint: 'http://localhost:8080/realms/projectx/auth',
|
||||
token_endpoint: 'http://localhost:8080/realms/projectx/token',
|
||||
jwks_uri: 'http://localhost:8080/realms/projectx/jwks',
|
||||
userinfo_endpoint: undefined,
|
||||
end_session_endpoint: undefined,
|
||||
};
|
||||
mockFetchSuccess(keycloakDoc as never);
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('http://localhost:8080/realms/projectx')).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('P0-4: endpoint SSRF validation', () => {
|
||||
it('calls ensureSafeUrl on token_endpoint', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/token', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on jwks_uri', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/jwks', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on userinfo_endpoint when present', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/userinfo', expect.any(Object));
|
||||
});
|
||||
|
||||
it('calls ensureSafeUrl on end_session_endpoint when present', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith('https://idp.example.com/logout', expect.any(Object));
|
||||
});
|
||||
|
||||
it('skips ensureSafeUrl for optional endpoints when absent', async () => {
|
||||
const minimalDoc = {
|
||||
issuer: 'https://idp.example.com',
|
||||
authorization_endpoint: 'https://idp.example.com/auth',
|
||||
token_endpoint: 'https://idp.example.com/token',
|
||||
jwks_uri: 'https://idp.example.com/jwks',
|
||||
};
|
||||
mockFetchSuccess(minimalDoc as never);
|
||||
const service = makeService();
|
||||
vi.mocked(ensureSafeUrl).mockClear();
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
// Only token + jwks called, not userinfo or end_session
|
||||
const calls = vi.mocked(ensureSafeUrl).mock.calls.map(([url]) => url);
|
||||
expect(calls).not.toContain('https://idp.example.com/userinfo');
|
||||
expect(calls).not.toContain('https://idp.example.com/logout');
|
||||
});
|
||||
|
||||
it('throws InternalServerErrorException when an endpoint fails SSRF check', async () => {
|
||||
mockFetchSuccess();
|
||||
vi.mocked(ensureSafeUrl).mockRejectedValueOnce(new BadRequestException('URL host is not allowed'));
|
||||
const service = makeService();
|
||||
await expect(service.getDiscoveryDoc('https://idp.example.com')).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
|
||||
it('passes allowLocal=true in non-production environments', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService('development');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: true });
|
||||
});
|
||||
|
||||
it('passes allowLocal=false in production environment', async () => {
|
||||
mockFetchSuccess();
|
||||
const service = makeService('production');
|
||||
await service.getDiscoveryDoc('https://idp.example.com');
|
||||
expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { ensureSafeUrl } from '../../../common/utils/ssrf.utils';
|
||||
|
||||
export interface OidcDiscoveryDoc {
|
||||
issuer: string;
|
||||
@@ -29,7 +32,13 @@ interface RawDiscoveryDoc {
|
||||
export class OidcDiscoveryService {
|
||||
private readonly logger = new Logger(OidcDiscoveryService.name);
|
||||
private readonly cache = new Map<string, CacheEntry>();
|
||||
private readonly TTL = 60 * 60 * 1000; // 1 hour
|
||||
private readonly TTL: number;
|
||||
private readonly allowLocal: boolean;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.allowLocal = this.configService.get<string>('app.nodeEnv') !== 'production';
|
||||
this.TTL = this.configService.get<number>('oidcRuntime.discoveryCacheTtlMs') ?? 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
async getDiscoveryDoc(issuerUri: string): Promise<OidcDiscoveryDoc> {
|
||||
const normalized = issuerUri.replace(/\/$/, '');
|
||||
@@ -42,10 +51,25 @@ export class OidcDiscoveryService {
|
||||
const url = `${normalized}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'error' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = (await res.json()) as RawDiscoveryDoc;
|
||||
|
||||
// P0-3: Validate issuer matches the URI we requested
|
||||
const rawIssuer = (raw.issuer ?? '').replace(/\/$/, '');
|
||||
if (rawIssuer !== normalized) {
|
||||
throw new Error(`Issuer mismatch: expected "${normalized}", got "${raw.issuer}"`);
|
||||
}
|
||||
|
||||
// P0-4: Validate all endpoint URLs are SSRF-safe
|
||||
const ssrfOpts = { allowLocal: this.allowLocal };
|
||||
await Promise.all([
|
||||
ensureSafeUrl(raw.token_endpoint, ssrfOpts),
|
||||
ensureSafeUrl(raw.jwks_uri, ssrfOpts),
|
||||
raw.userinfo_endpoint ? ensureSafeUrl(raw.userinfo_endpoint, ssrfOpts) : Promise.resolve(),
|
||||
raw.end_session_endpoint ? ensureSafeUrl(raw.end_session_endpoint, ssrfOpts) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
const doc: OidcDiscoveryDoc = {
|
||||
issuer: raw.issuer,
|
||||
authorizationEndpoint: raw.authorization_endpoint,
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { OidcGroupMappingService } from './oidc-group-mapping.service';
|
||||
|
||||
function makeDb() {
|
||||
function makeTx() {
|
||||
return {
|
||||
query: {
|
||||
oidcGroupMappings: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
oidcGroupMappings: { findMany: vi.fn() },
|
||||
userPermissions: { findMany: vi.fn() },
|
||||
},
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb() {
|
||||
const tx = makeTx();
|
||||
return {
|
||||
tx,
|
||||
transaction: vi.fn().mockImplementation(async (cb: (tx: unknown) => Promise<void>) => cb(tx)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,60 +31,81 @@ describe('OidcGroupMappingService', () => {
|
||||
service = new OidcGroupMappingService(db as never);
|
||||
});
|
||||
|
||||
it('does nothing when groups array is empty', async () => {
|
||||
it('does nothing when no system mappings exist', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([]);
|
||||
await service.syncUserGroups(1, []);
|
||||
expect(db.query.oidcGroupMappings.findMany).not.toHaveBeenCalled();
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inserts permission assignments for matched groups', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(2);
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'manage_users' });
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'library_edit_metadata' });
|
||||
});
|
||||
|
||||
it('deduplicates permissionNames when multiple group claims map to the same permission', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: 'manage_users' },
|
||||
{ oidcGroupClaim: 'superadmins', permissionName: 'manage_users' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'superadmins']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when no mappings match the provided groups', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['unknown-group']);
|
||||
|
||||
expect(db.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips mappings with null permissionName', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: null },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.values).toHaveBeenCalledWith({ userId: 42, permissionName: 'library_edit_metadata' });
|
||||
});
|
||||
|
||||
it('uses onConflictDoNothing to avoid duplicate permission assignments', async () => {
|
||||
db.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
it('adds desired permissions and skips existing ones', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
|
||||
expect(db.onConflictDoNothing).toHaveBeenCalled();
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
expect(db.tx.delete).not.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' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, []);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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' },
|
||||
]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'superadmins']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
});
|
||||
|
||||
it('skips mappings with null permissionName', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([
|
||||
{ oidcGroupClaim: 'admins', permissionName: null },
|
||||
{ oidcGroupClaim: 'editors', permissionName: 'library_edit_metadata' },
|
||||
]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins', 'editors']);
|
||||
|
||||
expect(db.tx.insert).toHaveBeenCalledTimes(1);
|
||||
expect(db.tx.values).toHaveBeenCalledWith([{ userId: 42, permissionName: 'library_edit_metadata' }]);
|
||||
});
|
||||
|
||||
it('does not insert permissions user already has', async () => {
|
||||
db.tx.query.oidcGroupMappings.findMany.mockResolvedValue([{ oidcGroupClaim: 'admins', permissionName: 'manage_users' }]);
|
||||
db.tx.query.userPermissions.findMany.mockResolvedValue([{ userId: 42, permissionName: 'manage_users' }]);
|
||||
|
||||
await service.syncUserGroups(42, ['admins']);
|
||||
|
||||
expect(db.tx.insert).not.toHaveBeenCalled();
|
||||
expect(db.tx.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../../db/db.module';
|
||||
@@ -13,20 +13,58 @@ export class OidcGroupMappingService {
|
||||
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Full-sync OIDC group permissions for a user.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async syncUserGroups(userId: number, groups: string[]): Promise<void> {
|
||||
if (groups.length === 0) return;
|
||||
const start = Date.now();
|
||||
this.logger.log(`[auth.oidc_group_sync] [start] userId=${userId} groupCount=${groups.length} - group sync started`);
|
||||
|
||||
const mappings = await this.db.query.oidcGroupMappings.findMany({
|
||||
where: inArray(schema.oidcGroupMappings.oidcGroupClaim, groups),
|
||||
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;
|
||||
|
||||
const allOidcPermissions = new Set(allMappings.map((m) => m.permissionName).filter(Boolean) as string[]);
|
||||
|
||||
// Desired permissions based on the user's current groups.
|
||||
const desired = new Set(
|
||||
allMappings.filter((m) => m.permissionName && groups.includes(m.oidcGroupClaim)).map((m) => m.permissionName as string),
|
||||
);
|
||||
|
||||
// Current user permissions that are OIDC-managed.
|
||||
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 toAdd = [...desired].filter((p) => !currentUserPerms.some((up) => up.permissionName === p));
|
||||
const toRemove = currentOidcPerms.filter((p) => !desired.has(p));
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await tx
|
||||
.insert(schema.userPermissions)
|
||||
.values(toAdd.map((permissionName) => ({ userId, permissionName })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
await tx
|
||||
.delete(schema.userPermissions)
|
||||
.where(and(eq(schema.userPermissions.userId, userId), inArray(schema.userPermissions.permissionName, toRemove)));
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[auth.oidc_group_sync] [end] userId=${userId} durationMs=${Date.now() - start} added=${toAdd.length} removed=${toRemove.length} - group sync completed`,
|
||||
);
|
||||
});
|
||||
|
||||
const permissionNames = [...new Set(mappings.flatMap((m) => (m.permissionName ? [m.permissionName] : [])))];
|
||||
if (permissionNames.length === 0) return;
|
||||
|
||||
for (const permissionName of permissionNames) {
|
||||
await this.db.insert(schema.userPermissions).values({ userId, permissionName }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
this.logger.debug(`Synced ${permissionNames.length} permission(s) for user ${userId} from OIDC groups`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,168 @@
|
||||
import { OidcStateService } from './oidc-state.service';
|
||||
|
||||
const mockConfig = { get: vi.fn().mockReturnValue(undefined) };
|
||||
|
||||
function makeDb(overrides: Partial<ReturnType<typeof makeDb>> = {}) {
|
||||
const deleteMock = vi.fn().mockReturnThis();
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: deleteMock }),
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||||
...overrides,
|
||||
};
|
||||
return { db, deleteMock };
|
||||
}
|
||||
|
||||
describe('OidcStateService', () => {
|
||||
let service: OidcStateService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcStateService();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('returns a non-empty base64url string', () => {
|
||||
const state = service.generate();
|
||||
it('returns a non-empty base64url string', async () => {
|
||||
const { db } = makeDb();
|
||||
db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const state = await service.generate();
|
||||
expect(typeof state).toBe('string');
|
||||
expect(state.length).toBeGreaterThan(0);
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('returns unique values on each call', () => {
|
||||
const a = service.generate();
|
||||
const b = service.generate();
|
||||
it('returns unique values on each call', async () => {
|
||||
const { db } = makeDb();
|
||||
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();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('prunes expired states on generate', () => {
|
||||
const state = service.generate();
|
||||
// Advance past TTL (5 minutes)
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
it('inserts state with meta=null when no meta passed', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
// generate() triggers pruning
|
||||
service.generate();
|
||||
const state = await service.generate();
|
||||
|
||||
// The original state should now be expired and validateAndConsume returns false
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
expect(insertMock).toHaveBeenCalledOnce();
|
||||
const [[{ state: insertedState, meta }]] = valuesMock.mock.calls;
|
||||
expect(insertedState).toBe(state);
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('inserts state with serialized meta when meta passed', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
const metaPayload = { mode: 'link', userId: 42 };
|
||||
|
||||
await service.generate(metaPayload);
|
||||
|
||||
const [[{ meta }]] = valuesMock.mock.calls;
|
||||
expect(JSON.parse(meta as string)).toEqual(metaPayload);
|
||||
});
|
||||
|
||||
it('inserts state into the database with an expiry', async () => {
|
||||
const valuesMock = vi.fn().mockResolvedValue(undefined);
|
||||
const insertMock = vi.fn().mockReturnValue({ values: valuesMock });
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
|
||||
insert: insertMock,
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const before = Date.now();
|
||||
const state = await service.generate();
|
||||
const after = Date.now();
|
||||
|
||||
const [[{ state: insertedState, expiresAt }]] = valuesMock.mock.calls;
|
||||
expect(insertedState).toBe(state);
|
||||
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||
expect(expiresAt.getTime()).toBeLessThanOrEqual(after + 20 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('prunes expired states on each generate call', async () => {
|
||||
const whereMock = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({ where: whereMock }),
|
||||
insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
await service.generate();
|
||||
|
||||
expect(db.delete).toHaveBeenCalled();
|
||||
expect(whereMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAndConsume', () => {
|
||||
it('returns true for a valid, fresh state', () => {
|
||||
const state = service.generate();
|
||||
expect(service.validateAndConsume(state)).toBe(true);
|
||||
it('returns { valid: true } when delete removes a row (valid state, no meta)', async () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: null }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('valid-state');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns false on second use (one-time use)', () => {
|
||||
const state = service.generate();
|
||||
service.validateAndConsume(state);
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
it('returns { valid: true, meta } when row has serialized meta', async () => {
|
||||
const metaPayload = { mode: 'link', userId: 99 };
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([{ state: 'valid-state', meta: JSON.stringify(metaPayload) }]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('valid-state');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.meta).toEqual(metaPayload);
|
||||
});
|
||||
|
||||
it('returns false for unknown state', () => {
|
||||
expect(service.validateAndConsume('totally-fake-state')).toBe(false);
|
||||
it('returns { valid: false } when delete removes nothing (expired or unknown state)', async () => {
|
||||
const db = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
};
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
const result = await service.validateAndConsume('unknown-state');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for expired state', () => {
|
||||
const state = service.generate();
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
expect(service.validateAndConsume(state)).toBe(false);
|
||||
});
|
||||
it('uses DELETE...RETURNING for atomic one-time consumption', async () => {
|
||||
const returningMock = vi.fn().mockResolvedValue([]);
|
||||
const whereMock = vi.fn().mockReturnValue({ returning: returningMock });
|
||||
const deleteMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
const db = { delete: deleteMock, insert: vi.fn() };
|
||||
const service = new OidcStateService(db as never, mockConfig as never);
|
||||
|
||||
it('returns true at exactly TTL boundary', () => {
|
||||
const state = service.generate();
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
// At exactly TTL, Date.now() - ts === TTL, which is NOT > TTL, so still valid
|
||||
expect(service.validateAndConsume(state)).toBe(true);
|
||||
await service.validateAndConsume('some-state');
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledOnce();
|
||||
expect(whereMock).toHaveBeenCalledOnce();
|
||||
expect(returningMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { and, gt, lt, eq } from 'drizzle-orm';
|
||||
import type { 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 OidcStateService {
|
||||
private readonly states = new Map<string, number>();
|
||||
private readonly TTL = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly ttlMs: number;
|
||||
|
||||
generate(): string {
|
||||
// Prune expired states
|
||||
const now = Date.now();
|
||||
for (const [state, ts] of this.states) {
|
||||
if (now - ts > this.TTL) this.states.delete(state);
|
||||
}
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.ttlMs = this.configService.get<number>('oidcRuntime.stateTtlMs') ?? 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
async generate(meta?: Record<string, unknown>): Promise<string> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
this.states.set(state, Date.now());
|
||||
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 }),
|
||||
]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
validateAndConsume(state: string): boolean {
|
||||
const ts = this.states.get(state);
|
||||
if (!ts) return false;
|
||||
this.states.delete(state); // one-time use
|
||||
return Date.now() - ts <= this.TTL;
|
||||
async validateAndConsume(state: string): Promise<{ valid: boolean; 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())))
|
||||
.returning();
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OidcTokenClientService', () => {
|
||||
let fetchMock: vi.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcTokenClientService();
|
||||
service = new OidcTokenClientService({ get: vi.fn().mockReturnValue(undefined) } as never);
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface TokenResponse {
|
||||
accessToken: string;
|
||||
@@ -17,6 +18,11 @@ interface TokenEndpointResponse {
|
||||
@Injectable()
|
||||
export class OidcTokenClientService {
|
||||
private readonly logger = new Logger(OidcTokenClientService.name);
|
||||
private readonly tokenExchangeTimeoutMs: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.tokenExchangeTimeoutMs = this.configService.get<number>('oidcRuntime.tokenExchangeTimeoutMs') ?? 10_000;
|
||||
}
|
||||
|
||||
async exchangeCode(params: {
|
||||
code: string;
|
||||
@@ -44,7 +50,7 @@ export class OidcTokenClientService {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.timeout(this.tokenExchangeTimeoutMs),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('OidcTokenValidatorService', () => {
|
||||
let service: OidcTokenValidatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new OidcTokenValidatorService();
|
||||
service = new OidcTokenValidatorService({ get: vi.fn().mockReturnValue(undefined) } as never);
|
||||
vi.clearAllMocks();
|
||||
(createRemoteJWKSet as vi.Mock).mockReturnValue('mocked-jwks');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
|
||||
type JWKSGetter = ReturnType<typeof createRemoteJWKSet>;
|
||||
@@ -12,7 +13,13 @@ interface JWKSCacheEntry {
|
||||
export class OidcTokenValidatorService {
|
||||
private readonly logger = new Logger(OidcTokenValidatorService.name);
|
||||
private readonly jwksCache = new Map<string, JWKSCacheEntry>();
|
||||
private readonly JWKS_TTL = 6 * 3600 * 1000; // 6 hours
|
||||
private readonly JWKS_TTL: number;
|
||||
private readonly clockTolerance: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.JWKS_TTL = this.configService.get<number>('oidcRuntime.jwksCacheTtlMs') ?? 6 * 3600 * 1000;
|
||||
this.clockTolerance = this.configService.get<number>('oidcRuntime.clockToleranceSecs') ?? 30;
|
||||
}
|
||||
|
||||
private getJwks(jwksUri: string): JWKSGetter {
|
||||
const cached = this.jwksCache.get(jwksUri);
|
||||
@@ -30,7 +37,7 @@ export class OidcTokenValidatorService {
|
||||
const { payload } = await jwtVerify(idToken, jwks, {
|
||||
issuer: opts.issuer,
|
||||
audience: opts.clientId,
|
||||
clockTolerance: 30,
|
||||
clockTolerance: this.clockTolerance,
|
||||
});
|
||||
|
||||
if (payload.nonce !== opts.nonce) {
|
||||
@@ -53,7 +60,7 @@ export class OidcTokenValidatorService {
|
||||
const { payload } = await jwtVerify(logoutToken, jwks, {
|
||||
issuer: opts.issuer,
|
||||
audience: opts.clientId,
|
||||
clockTolerance: 30,
|
||||
clockTolerance: this.clockTolerance,
|
||||
});
|
||||
|
||||
// Logout tokens must have the backchannel-logout event
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
const APP_URL = 'http://localhost:5173';
|
||||
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
|
||||
|
||||
function makeService() {
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({
|
||||
@@ -29,11 +32,11 @@ function makeService() {
|
||||
validateIdToken: vi.fn().mockResolvedValue({ sub: 'sub-1' }),
|
||||
};
|
||||
const claimExtractor = {
|
||||
extract: vi.fn(),
|
||||
extract: vi.fn().mockReturnValue({ subject: 'sub-1', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] }),
|
||||
};
|
||||
const stateService = {
|
||||
generate: vi.fn(),
|
||||
validateAndConsume: vi.fn().mockReturnValue(true),
|
||||
generate: vi.fn().mockResolvedValue('state-token'),
|
||||
validateAndConsume: vi.fn().mockResolvedValue({ valid: true }),
|
||||
};
|
||||
const sessionRepo = {
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -48,12 +51,24 @@ function makeService() {
|
||||
findByOidcSubject: vi.fn(),
|
||||
findByUsername: vi.fn(),
|
||||
linkOidcIdentity: vi.fn(),
|
||||
unlinkOidcIdentity: vi.fn(),
|
||||
getUserOidcIdentity: vi.fn(),
|
||||
findPasswordHashById: vi.fn(),
|
||||
createOidcUser: vi.fn(),
|
||||
setPermissionsDirectly: vi.fn(),
|
||||
};
|
||||
const authService = {
|
||||
getRefreshTokenExpiryDate: vi.fn().mockReturnValue(new Date('2026-01-08T00:00:00Z')),
|
||||
issueTokensForUser: vi.fn(),
|
||||
issueTokensForUser: vi.fn().mockResolvedValue({ accessToken: 'token', user: {} }),
|
||||
};
|
||||
const auditEvents = {
|
||||
emit: vi.fn(),
|
||||
};
|
||||
const configService = {
|
||||
get: vi.fn().mockImplementation((key: string) => {
|
||||
if (key === 'app.appUrl') return APP_URL;
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new OidcService(
|
||||
@@ -68,79 +83,212 @@ function makeService() {
|
||||
backchannelLogout as never,
|
||||
userService as never,
|
||||
authService as never,
|
||||
auditEvents as never,
|
||||
configService as never,
|
||||
);
|
||||
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo };
|
||||
return { service, claimExtractor, userService, appSettings, authService, sessionRepo, stateService, auditEvents, discovery, tokenValidator };
|
||||
}
|
||||
|
||||
const BASE_CALLBACK = {
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: VALID_REDIRECT_URI,
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
};
|
||||
|
||||
describe('OidcService', () => {
|
||||
it('rejects callback when extracted subject is missing', async () => {
|
||||
const { service, claimExtractor } = makeService();
|
||||
claimExtractor.extract.mockReturnValue({
|
||||
subject: '',
|
||||
username: 'u1',
|
||||
name: 'User One',
|
||||
email: 'u1@example.com',
|
||||
groups: [],
|
||||
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);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleCallback(
|
||||
{
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
},
|
||||
{ setCookie: vi.fn() } as never,
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
it('returns state and authorizationEndpoint', async () => {
|
||||
const { service } = makeService();
|
||||
const result = await service.generateState();
|
||||
expect(result).toMatchObject({ state: 'state-token', authorizationEndpoint: 'https://issuer.example/auth' });
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses an OIDC user when auto-provision races on the new unique constraint', async () => {
|
||||
const { service, claimExtractor, userService, appSettings, authService, sessionRepo } = makeService();
|
||||
const existingUser = { id: 7, 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'] },
|
||||
describe('generateLinkState', () => {
|
||||
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 });
|
||||
expect(result.state).toBe('link-state');
|
||||
});
|
||||
claimExtractor.extract.mockReturnValue({
|
||||
subject: 'sub-1',
|
||||
username: 'u1',
|
||||
name: 'User One',
|
||||
email: 'u1@example.com',
|
||||
groups: [],
|
||||
});
|
||||
|
||||
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' });
|
||||
expect(result.state).toBe('preview-state');
|
||||
});
|
||||
userService.findByOidcSubject.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
|
||||
userService.createOidcUser.mockRejectedValueOnce({ code: '23505' });
|
||||
authService.issueTokensForUser.mockResolvedValue({ accessToken: 'token' });
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.handleCallback(
|
||||
{
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'https://app.example/callback',
|
||||
nonce: 'nonce',
|
||||
state: 'state',
|
||||
},
|
||||
{ setCookie: vi.fn() } as never,
|
||||
),
|
||||
).resolves.toEqual({ accessToken: 'token' });
|
||||
describe('handleCallback', () => {
|
||||
it('rejects callback when state is invalid', async () => {
|
||||
const { service, stateService } = makeService();
|
||||
stateService.validateAndConsume.mockResolvedValue({ valid: false });
|
||||
await expect(service.handleCallback(BASE_CALLBACK, {} as never)).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
expect(userService.setPermissionsDirectly).not.toHaveBeenCalled();
|
||||
expect(sessionRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7,
|
||||
oidcSubject: 'sub-1',
|
||||
oidcIssuer: 'https://issuer.example',
|
||||
expiresAt: new Date('2026-01-08T00:00:00Z'),
|
||||
}),
|
||||
);
|
||||
it('rejects callback when redirectUri does not match allowed app URL', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.handleCallback({ ...BASE_CALLBACK, redirectUri: 'https://evil.example/callback' }, {} as never)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects callback when extracted subject is missing', async () => {
|
||||
const { service, claimExtractor } = makeService();
|
||||
claimExtractor.extract.mockReturnValue({ subject: '', username: 'u1', name: 'User One', email: 'u1@example.com', groups: [] });
|
||||
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);
|
||||
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' } });
|
||||
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'] });
|
||||
|
||||
const result = await service.handleCallback(BASE_CALLBACK, {} as never);
|
||||
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 } });
|
||||
|
||||
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(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);
|
||||
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('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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('unlinks identity on correct password and emits OidcIdentityUnlinked', async () => {
|
||||
const { service, userService, 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' });
|
||||
|
||||
await service.unlinkIdentity(5, 'correct-password');
|
||||
expect(userService.unlinkOidcIdentity).toHaveBeenCalledWith(5);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'auth.oidc.identity_unlinked' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { compare } from 'bcryptjs';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { Permission } from '@projectx/types';
|
||||
import { AuditAction, AuditResource, OidcCallbackResponse, OidcErrorCode, Permission } from '@projectx/types';
|
||||
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../../audit/audit-events.service';
|
||||
import { AppSettingsService } from '../../app-settings/app-settings.service';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
@@ -16,10 +19,8 @@ import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
|
||||
const directCode = (error as { code?: unknown }).code;
|
||||
if (directCode === '23505') return true;
|
||||
|
||||
if (!(error instanceof Error)) return false;
|
||||
const causeCode = (error.cause as { code?: unknown } | undefined)?.code;
|
||||
return causeCode === '23505';
|
||||
@@ -29,9 +30,19 @@ function toThrowable(error: unknown, fallbackMessage: string): Error {
|
||||
return error instanceof Error ? error : new UnauthorizedException(fallbackMessage);
|
||||
}
|
||||
|
||||
function normalizeRedirectUri(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.origin + u.pathname;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
private readonly logger = new Logger(OidcService.name);
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly appSettings: AppSettingsService,
|
||||
@@ -45,34 +56,100 @@ export class OidcService {
|
||||
private readonly backchannelLogout: BackchannelLogoutService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
generateState(): string {
|
||||
return this.stateService.generate();
|
||||
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)]);
|
||||
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 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)]);
|
||||
return { state, authorizationEndpoint: disc.authorizationEndpoint };
|
||||
}
|
||||
|
||||
handleBackchannelLogout(logoutToken: string): Promise<void> {
|
||||
return this.backchannelLogout.handleLogout(logoutToken);
|
||||
}
|
||||
|
||||
async handleCallback(params: { code: string; codeVerifier: string; redirectUri: string; nonce: string; state: string }, reply: FastifyReply) {
|
||||
if (!this.stateService.validateAndConsume(params.state)) {
|
||||
throw new UnauthorizedException('Invalid or expired state');
|
||||
async getLinkedIdentity(userId: number) {
|
||||
return this.userService.getUserOidcIdentity(userId);
|
||||
}
|
||||
|
||||
async unlinkIdentity(userId: 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);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId,
|
||||
actorUsername: 'self',
|
||||
action: AuditAction.OidcIdentityUnlinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: userId,
|
||||
description: `User unlinked OIDC identity (issuer: ${identity?.oidcIssuer ?? 'unknown'})`,
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: { code: string; codeVerifier: string; redirectUri: string; nonce: string; state: string },
|
||||
reply: FastifyReply,
|
||||
): Promise<OidcCallbackResponse> {
|
||||
const stateResult = await this.stateService.validateAndConsume(params.state);
|
||||
if (!stateResult.valid) {
|
||||
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');
|
||||
|
||||
// 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 tokens = await this.tokenClient.exchangeCode({
|
||||
code: params.code,
|
||||
codeVerifier: params.codeVerifier,
|
||||
redirectUri: params.redirectUri,
|
||||
tokenEndpoint: disc.tokenEndpoint,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
});
|
||||
let tokens: Awaited<ReturnType<typeof this.tokenClient.exchangeCode>>;
|
||||
try {
|
||||
tokens = await this.tokenClient.exchangeCode({
|
||||
code: params.code,
|
||||
codeVerifier: params.codeVerifier,
|
||||
redirectUri: params.redirectUri,
|
||||
tokenEndpoint: disc.tokenEndpoint,
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Could not complete sign-in with your provider. Please try again or contact your administrator.',
|
||||
errorCode: OidcErrorCode.TOKEN_EXCHANGE_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const idTokenClaims = await this.tokenValidator.validateIdToken(tokens.idToken, {
|
||||
issuer: disc.issuer,
|
||||
@@ -89,12 +166,43 @@ export class OidcService {
|
||||
const claims = this.claimExtractor.extract(idTokenClaims as Record<string, unknown>, userInfoClaims, config.claimMapping);
|
||||
if (!claims.subject) {
|
||||
this.logger.warn('[auth.oidc_callback] [fail] errorClass=UnauthorizedException error="missing sub claim" - OIDC callback failed');
|
||||
throw new UnauthorizedException('Invalid ID token: missing subject claim');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Could not complete sign-in with your provider. Please try again or contact your administrator.',
|
||||
errorCode: OidcErrorCode.TOKEN_EXCHANGE_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const mode = (stateResult.meta?.mode as string) ?? 'login';
|
||||
|
||||
if (mode === 'preview') {
|
||||
return {
|
||||
mode: 'preview',
|
||||
claims: {
|
||||
raw: { ...(idTokenClaims as Record<string, unknown>), ...userInfoClaims },
|
||||
mapped: { username: claims.username, name: claims.name, email: claims.email, groups: claims.groups },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'link') {
|
||||
const targetUserId = stateResult.meta?.userId as number;
|
||||
await this.userService.linkOidcIdentity(targetUserId, claims.subject, disc.issuer, claims.avatarUrl);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: targetUserId,
|
||||
actorUsername: 'self',
|
||||
action: AuditAction.OidcIdentityLinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: targetUserId,
|
||||
description: `User linked OIDC identity (issuer: ${disc.issuer}, subject: ${claims.subject})`,
|
||||
});
|
||||
|
||||
return { mode: 'link', linked: true };
|
||||
}
|
||||
|
||||
// Default: login flow
|
||||
const user = await this.findOrProvisionUser(claims, config, disc.issuer);
|
||||
|
||||
// Track OIDC session for backchannel logout
|
||||
const sid = (idTokenClaims as Record<string, unknown>).sid ? String((idTokenClaims as Record<string, unknown>).sid) : undefined;
|
||||
await this.sessionRepo.create({
|
||||
userId: user.id,
|
||||
@@ -102,10 +210,21 @@ export class OidcService {
|
||||
oidcIssuer: disc.issuer,
|
||||
oidcSessionId: sid,
|
||||
idTokenHint: tokens.idToken,
|
||||
idpRefreshToken: tokens.refreshToken ?? null,
|
||||
expiresAt: this.authService.getRefreshTokenExpiryDate(),
|
||||
});
|
||||
|
||||
return this.authService.issueTokensForUser(user.id, reply);
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.OidcLogin,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: user.id,
|
||||
description: `User logged in via OIDC (issuer: ${disc.issuer})`,
|
||||
});
|
||||
|
||||
const authResult = await this.authService.issueTokensForUser(user.id, reply);
|
||||
return { mode: 'login' as const, ...authResult } as OidcCallbackResponse;
|
||||
}
|
||||
|
||||
private async findOrProvisionUser(
|
||||
@@ -113,16 +232,22 @@ export class OidcService {
|
||||
config: Awaited<ReturnType<AppSettingsService['getOidcConfig']>>,
|
||||
issuer: string,
|
||||
) {
|
||||
// 1. Look up by OIDC subject+issuer (primary key for OIDC identity)
|
||||
let user = await this.userService.findByOidcSubject(claims.subject, issuer);
|
||||
|
||||
// 2. Fallback: look up by username and link if allowed
|
||||
if (!user && config.autoProvision.allowLocalLinking) {
|
||||
const byUsername = await this.userService.findByUsername(claims.username);
|
||||
if (byUsername) {
|
||||
let linkConflict: unknown;
|
||||
try {
|
||||
await this.userService.linkOidcIdentity(byUsername.id, claims.subject, issuer, claims.avatarUrl);
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: byUsername.id,
|
||||
actorUsername: byUsername.username,
|
||||
action: AuditAction.OidcIdentityLinked,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: byUsername.id,
|
||||
description: `OIDC identity auto-linked to existing local account (issuer: ${issuer})`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
linkConflict = error;
|
||||
@@ -134,7 +259,6 @@ export class OidcService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-provision new user
|
||||
let createdUser = false;
|
||||
if (!user && config.autoProvision.enabled) {
|
||||
try {
|
||||
@@ -154,22 +278,36 @@ export class OidcService {
|
||||
}
|
||||
|
||||
if (createdUser && config.autoProvision.defaultPermissionNames?.length) {
|
||||
await this.userService.setPermissionsDirectly(user.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
await this.userService.setPermissionsDirectly(user!.id, config.autoProvision.defaultPermissionNames as Permission[]);
|
||||
}
|
||||
|
||||
if (createdUser && user) {
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user.id,
|
||||
actorUsername: user.username,
|
||||
action: AuditAction.OidcUserProvisioned,
|
||||
resource: AuditResource.OidcIdentity,
|
||||
resourceId: user.id,
|
||||
description: `User auto-provisioned via OIDC (issuer: ${issuer}, subject: ${claims.subject})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found and auto-provisioning is disabled');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your account has not been set up. Contact your administrator for access.',
|
||||
errorCode: OidcErrorCode.USER_NOT_PROVISIONED,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
throw new UnauthorizedException('Account is deactivated');
|
||||
throw new UnauthorizedException({
|
||||
message: 'Your account has been deactivated. Contact your administrator.',
|
||||
errorCode: OidcErrorCode.USER_INACTIVE,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync OIDC group-to-role mappings
|
||||
if (claims.groups.length > 0) {
|
||||
await this.groupMapping.syncUserGroups(user.id, claims.groups);
|
||||
}
|
||||
await this.groupMapping.syncUserGroups(user.id, claims.groups);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -236,6 +236,26 @@ export class UserRepository {
|
||||
.where(eq(schema.users.id, userId));
|
||||
}
|
||||
|
||||
async unlinkOidcIdentity(userId: number) {
|
||||
await this.db.update(schema.users).set({ oidcSubject: null, oidcIssuer: null, provisioningMethod: 'local' }).where(eq(schema.users.id, userId));
|
||||
}
|
||||
|
||||
async getUserOidcIdentity(userId: number): Promise<{ oidcSubject: string | null; oidcIssuer: string | null } | null> {
|
||||
const row = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
columns: { oidcSubject: true, oidcIssuer: true },
|
||||
});
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
async findPasswordHashById(userId: number): Promise<string | null> {
|
||||
const row = await this.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId),
|
||||
columns: { passwordHash: true },
|
||||
});
|
||||
return row?.passwordHash ?? null;
|
||||
}
|
||||
|
||||
async createOidcUser(data: { username: string; name: string; email?: string; oidcSubject: string; oidcIssuer: string; avatarUrl?: string }) {
|
||||
const passwordHash = await hash(`OIDC_USER_${randomUUID()}`, 12);
|
||||
const [user] = await this.db
|
||||
|
||||
@@ -34,6 +34,18 @@ export class UserService {
|
||||
return this.userRepo.linkOidcIdentity(userId, oidcSubject, oidcIssuer, avatarUrl);
|
||||
}
|
||||
|
||||
unlinkOidcIdentity(userId: number) {
|
||||
return this.userRepo.unlinkOidcIdentity(userId);
|
||||
}
|
||||
|
||||
getUserOidcIdentity(userId: number) {
|
||||
return this.userRepo.getUserOidcIdentity(userId);
|
||||
}
|
||||
|
||||
findPasswordHashById(userId: number) {
|
||||
return this.userRepo.findPasswordHashById(userId);
|
||||
}
|
||||
|
||||
createOidcUser(data: Parameters<UserRepository['createOidcUser']>[0]) {
|
||||
return this.userRepo.createOidcUser(data);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user