[ee] feat(scim): OAuth2 client-credentials grant for SCIM provisioning

Support OAuth 2.0 client-credentials authentication for SCIM provisioning
(e.g. Microsoft Entra ID) alongside the existing static bearer token, so
identity providers can use short-lived, rotatable access tokens.

Backend (EE companion PR modifies scim_ee.rs):
- Unauthenticated token endpoint POST /api/scim_token/token issues a
  short-lived scope:scim JWT via the client-credentials grant.
- has_scim_token validates SCIM JWTs (when OAuth is configured) in addition
  to the static token — fully backward compatible.
- Super-admin config endpoints (generate/rotate/disable) store the client
  secret hashed (SHA-256). New scim_oauth global setting is agent-worker
  blocked and live-reloaded.

Frontend:
- OAuth 2.0 client-credentials section in the SCIM/SAML instance settings:
  enable toggle, generate-secret (shown once), copyable token endpoint and
  client ID, regenerate/disable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-07-14 10:36:08 +00:00
parent 710a13a59d
commit a89fcf72fa
10 changed files with 294 additions and 13 deletions
@@ -13,6 +13,7 @@
import { createEventDispatcher } from 'svelte'
import { setLicense } from '$lib/enterpriseUtils'
import AuthSettings from './AuthSettings.svelte'
import ScimOauthSettings from './ScimOauthSettings.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
import InstanceSetting from './InstanceSetting.svelte'
import { writable, type Writable } from 'svelte/store'
@@ -1171,6 +1172,9 @@
{version}
{oauths}
/>
{#if setting.key === 'scim_token'}
<ScimOauthSettings />
{/if}
{/each}
</div>
{/snippet}
@@ -0,0 +1,166 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { SettingService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard } from '$lib/utils'
import { Copy, KeyRound, RefreshCw } from 'lucide-svelte'
import { onMount } from 'svelte'
import Toggle from './Toggle.svelte'
let loading = $state(true)
let enabled = $state(false)
let clientId = $state<string | undefined>(undefined)
let tokenEndpoint = $state('')
let generating = $state(false)
// Plaintext secret returned once by the generate call — never re-fetchable.
let newSecret = $state<string | undefined>(undefined)
async function load() {
loading = true
try {
const res = await SettingService.getScimOauthConfig()
enabled = res.enabled
clientId = res.client_id
tokenEndpoint = res.token_endpoint
} catch (e) {
sendUserToast(`Failed to load SCIM OAuth config: ${e}`, true)
} finally {
loading = false
}
}
async function generate() {
generating = true
try {
const res = await SettingService.generateScimOauthSecret({
requestBody: clientId ? { client_id: clientId } : {}
})
clientId = res.client_id
tokenEndpoint = res.token_endpoint
newSecret = res.client_secret
enabled = true
sendUserToast('Client secret generated — copy it now, it will not be shown again')
} catch (e) {
sendUserToast(`Failed to generate client secret: ${e}`, true)
} finally {
generating = false
}
}
async function toggle(target: boolean) {
if (target) {
await generate()
} else {
try {
await SettingService.disableScimOauthConfig()
enabled = false
clientId = undefined
newSecret = undefined
sendUserToast('OAuth client credentials disabled')
} catch (e) {
sendUserToast(`Failed to disable OAuth client credentials: ${e}`, true)
}
}
}
onMount(load)
</script>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-primary">OAuth 2.0 client credentials</span>
<span class="text-xs text-secondary max-w-lg">
Let an identity provider (e.g. Microsoft Entra ID) obtain short-lived access tokens via the
OAuth 2.0 client-credentials grant instead of a static bearer token. Both methods stay
valid.
</span>
</div>
<Toggle
disabled={loading || generating}
checked={enabled}
on:change={(e) => toggle(e.detail)}
options={{ right: 'Enabled' }}
/>
</div>
{#if enabled}
<div class="flex flex-col gap-3 border rounded-md p-3 bg-surface-secondary">
<div class="flex flex-col gap-1">
<span class="text-2xs font-semibold text-secondary uppercase">Token endpoint</span>
<div class="flex items-center gap-2">
<code class="text-xs break-all grow">{tokenEndpoint}</code>
<Button
size="xs2"
color="light"
variant="border"
startIcon={{ icon: Copy }}
iconOnly
on:click={() => copyToClipboard(tokenEndpoint)}
/>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-2xs font-semibold text-secondary uppercase">Client ID</span>
<div class="flex items-center gap-2">
<code class="text-xs break-all grow">{clientId ?? ''}</code>
{#if clientId}
<Button
size="xs2"
color="light"
variant="border"
startIcon={{ icon: Copy }}
iconOnly
on:click={() => copyToClipboard(clientId)}
/>
{/if}
</div>
</div>
{#if newSecret}
<div class="flex flex-col gap-1">
<span class="text-2xs font-semibold text-secondary uppercase">Client secret</span>
<div class="flex items-center gap-2">
<code class="text-xs break-all grow text-primary">{newSecret}</code>
<Button
size="xs2"
color="dark"
startIcon={{ icon: Copy }}
iconOnly
on:click={() => copyToClipboard(newSecret)}
/>
</div>
<span class="text-2xs text-orange-600 dark:text-orange-400">
Copy this secret now — it will not be shown again. Regenerate to get a new one.
</span>
</div>
{/if}
<div class="flex gap-2">
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: RefreshCw }}
disabled={generating}
on:click={generate}
>
Regenerate secret
</Button>
</div>
</div>
{:else if !loading}
<div class="flex">
<Button
size="xs"
color="dark"
startIcon={{ icon: KeyRound }}
disabled={generating}
on:click={generate}
>
Generate client credentials
</Button>
</div>
{/if}
</div>