diff --git a/dev/oidc/dex-config.yaml b/dev/oidc/dex-config.yaml new file mode 100644 index 000000000..dcdb4def7 --- /dev/null +++ b/dev/oidc/dex-config.yaml @@ -0,0 +1,30 @@ +issuer: http://127.0.0.1:5556/dex +storage: + type: memory +web: + http: 0.0.0.0:5556 +frontend: + issuer: Pulse Mock IDP + dir: /srv/dex/web +logger: + level: info + format: text +oauth2: + skipApprovalScreen: true + responseTypes: ["code", "token", "id_token"] + alwaysShowLoginScreen: true +staticClients: + - id: pulse-dev + name: Pulse Dev + secret: pulse-secret + redirectURIs: + - http://127.0.0.1:5173/api/oidc/callback + - http://127.0.0.1:7655/api/oidc/callback + - http://127.0.0.1:8765/api/oidc/callback +staticPasswords: + - email: admin@example.com + hash: "$2a$10$uo8fC/3BtvIULFvS7/NuRe6Bn3NmidSXHHiAchpdZEiBBV3IcJKfy" + username: admin + userID: 19d82f09-9a6b-4f38-a6d8-2c4ed1faff42 + displayName: Admin User +enablePasswordDB: true diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index edd23daf7..66d872b7a 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js'; +import { Component, createSignal, Show, onMount, lazy, Suspense, createEffect } from 'solid-js'; import { setBasicAuth } from '@/utils/apiClient'; import { STORAGE_KEYS } from '@/constants'; @@ -9,13 +9,46 @@ interface LoginProps { onLogin: () => void; } +interface SecurityStatus { + hasAuthentication: boolean; + oidcEnabled?: boolean; + oidcIssuer?: string; + oidcClientId?: string; + oidcEnvOverrides?: Record; +} + export const Login: Component = (props) => { const [username, setUsername] = createSignal(''); const [password, setPassword] = createSignal(''); const [error, setError] = createSignal(''); const [loading, setLoading] = createSignal(false); - const [authStatus, setAuthStatus] = createSignal<{ hasAuthentication: boolean } | null>(null); + const [authStatus, setAuthStatus] = createSignal(null); const [loadingAuth, setLoadingAuth] = createSignal(true); + const [oidcLoading, setOidcLoading] = createSignal(false); + const [oidcError, setOidcError] = createSignal(''); + const [oidcMessage, setOidcMessage] = createSignal(''); + const [autoOidcTriggered, setAutoOidcTriggered] = createSignal(false); + + const supportsOIDC = () => Boolean(authStatus()?.oidcEnabled); + + const resolveOidcError = (reason?: string | null) => { + switch (reason) { + case 'email_restricted': + return 'Your account email is not permitted to access Pulse.'; + case 'domain_restricted': + return 'Your email domain is not allowed for Pulse access.'; + case 'group_restricted': + return 'Your account is not part of an authorized group to use Pulse.'; + case 'invalid_state': + return 'The sign-in attempt expired. Please try again.'; + case 'exchange_failed': + return 'We could not complete the sign-in request. Please try again shortly.'; + case 'session_failed': + return 'Login succeeded but we could not create a session. Try again.'; + default: + return 'Single sign-on failed. Please try again or contact an administrator.'; + } + }; onMount(async () => { // Apply saved theme preference from localStorage @@ -32,7 +65,25 @@ export const Login: Component = (props) => { document.documentElement.classList.remove('dark'); } } - + + const params = new URLSearchParams(window.location.search); + const oidcStatus = params.get('oidc'); + if (oidcStatus === 'error') { + const reason = params.get('oidc_error'); + setOidcError(resolveOidcError(reason)); + setError(''); + } else if (oidcStatus === 'success') { + setOidcMessage('Signed in successfully. Loading Pulse…'); + setError(''); + } + if (oidcStatus) { + params.delete('oidc'); + params.delete('oidc_error'); + const newQuery = params.toString(); + const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`; + window.history.replaceState({}, document.title, newUrl); + } + console.log('[Login] Starting auth check...'); try { const response = await fetch('/api/security/status'); @@ -60,6 +111,56 @@ export const Login: Component = (props) => { } }); + const startOidcLogin = async () => { + if (!supportsOIDC()) return; + + setOidcError(''); + setOidcMessage(''); + setError(''); + setOidcLoading(true); + + let redirecting = false; + try { + const response = await fetch('/api/oidc/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + returnTo: `${window.location.pathname}${window.location.search}` + }) + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || 'Failed to initiate OIDC login'); + } + + const data = await response.json(); + if (data.authorizationUrl) { + redirecting = true; + window.location.href = data.authorizationUrl; + return; + } + + throw new Error('OIDC response missing authorization URL'); + } catch (err) { + console.error('[Login] Failed to start OIDC login:', err); + setOidcError('Failed to start single sign-on. Please try again.'); + } finally { + if (!redirecting) { + setOidcLoading(false); + } + } + }; + + createEffect(() => { + if (!loadingAuth() && supportsOIDC() && !autoOidcTriggered()) { + setAutoOidcTriggered(true); + startOidcLogin(); + } + }); + const handleSubmit = async (e: Event) => { e.preventDefault(); setError(''); @@ -161,7 +262,7 @@ export const Login: Component = (props) => { > } + fallback={} > @@ -187,8 +288,13 @@ const LoginForm: Component<{ error: () => string; loading: () => boolean; handleSubmit: (e: Event) => void; + supportsOIDC: () => boolean; + startOidcLogin: () => void | Promise; + oidcLoading: () => boolean; + oidcError: () => string; + oidcMessage: () => string; }> = (props) => { - const { username, setUsername, password, setPassword, error, loading, handleSubmit } = props; + const { username, setUsername, password, setPassword, error, loading, handleSubmit, supportsOIDC, startOidcLogin, oidcLoading, oidcError, oidcMessage } = props; return (
@@ -212,6 +318,49 @@ const LoginForm: Component<{

+ +
+ + +
+ {oidcError()} +
+
+ +
+ {oidcMessage()} +
+
+
+ + or + +
+

Use your admin credentials to sign in below.

+
+
@@ -312,4 +461,4 @@ const LoginForm: Component<{
); -}; \ No newline at end of file +}; diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx new file mode 100644 index 000000000..613f9b492 --- /dev/null +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -0,0 +1,417 @@ +import { Component, Show, createSignal, onMount } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { Card } from '@/components/shared/Card'; +import { SectionHeader } from '@/components/shared/SectionHeader'; +import { Toggle } from '@/components/shared/Toggle'; +import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; +import { notificationStore } from '@/stores/notifications'; + +interface OIDCConfigResponse { + enabled: boolean; + issuerUrl: string; + clientId: string; + redirectUrl: string; + scopes: string[]; + usernameClaim: string; + emailClaim: string; + groupsClaim: string; + allowedGroups: string[]; + allowedDomains: string[]; + allowedEmails: string[]; + clientSecretSet: boolean; + envOverrides?: Record; + defaultRedirect: string; +} + +const listToString = (values?: string[]) => (values && values.length > 0 ? values.join(', ') : ''); +const splitList = (input: string) => input.split(/[,\s]+/).map((v) => v.trim()).filter(Boolean); + +interface Props { + onConfigUpdated?: (config: OIDCConfigResponse) => void; +} + +export const OIDCPanel: Component = (props) => { + const [config, setConfig] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [advancedOpen, setAdvancedOpen] = createSignal(false); + + const [form, setForm] = createStore({ + enabled: false, + issuerUrl: '', + clientId: '', + redirectUrl: '', + scopes: '', + usernameClaim: 'preferred_username', + emailClaim: 'email', + groupsClaim: '', + allowedGroups: '', + allowedDomains: '', + allowedEmails: '', + clientSecret: '', + clearSecret: false, + }); + + const isEnvLocked = () => { + const env = config()?.envOverrides; + return env ? Object.keys(env).length > 0 : false; + }; + + const resetForm = (data: OIDCConfigResponse | null) => { + if (!data) { + setForm({ + enabled: false, + issuerUrl: '', + clientId: '', + redirectUrl: '', + scopes: '', + usernameClaim: 'preferred_username', + emailClaim: 'email', + groupsClaim: '', + allowedGroups: '', + allowedDomains: '', + allowedEmails: '', + clientSecret: '', + clearSecret: false, + }); + return; + } + + setForm({ + enabled: data.enabled, + issuerUrl: data.issuerUrl ?? '', + clientId: data.clientId ?? '', + redirectUrl: data.redirectUrl || data.defaultRedirect || '', + scopes: data.scopes?.join(' ') ?? 'openid profile email', + usernameClaim: data.usernameClaim || 'preferred_username', + emailClaim: data.emailClaim || 'email', + groupsClaim: data.groupsClaim ?? '', + allowedGroups: listToString(data.allowedGroups), + allowedDomains: listToString(data.allowedDomains), + allowedEmails: listToString(data.allowedEmails), + clientSecret: '', + clearSecret: false, + }); + }; + + const loadConfig = async () => { + setLoading(true); + try { + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/security/oidc'); + if (!response.ok) { + throw new Error(`Failed to load OIDC settings (${response.status})`); + } + const data = (await response.json()) as OIDCConfigResponse; + setConfig(data); + resetForm(data); + props.onConfigUpdated?.(data); + } catch (error) { + console.error('[OIDCPanel] Failed to load config:', error); + notificationStore.error('Failed to load OIDC settings'); + setConfig(null); + resetForm(null); + } finally { + setLoading(false); + } + }; + + onMount(() => { + loadConfig(); + }); + + const handleSave = async (event?: Event) => { + event?.preventDefault(); + if (isEnvLocked()) { + return; + } + + setSaving(true); + try { + const payload: Record = { + enabled: form.enabled, + issuerUrl: form.issuerUrl.trim(), + clientId: form.clientId.trim(), + redirectUrl: form.redirectUrl.trim(), + scopes: splitList(form.scopes), + usernameClaim: form.usernameClaim.trim(), + emailClaim: form.emailClaim.trim(), + groupsClaim: form.groupsClaim.trim(), + allowedGroups: splitList(form.allowedGroups), + allowedDomains: splitList(form.allowedDomains), + allowedEmails: splitList(form.allowedEmails), + }; + + if (form.clientSecret.trim() !== '') { + payload.clientSecret = form.clientSecret.trim(); + } else if (form.clearSecret) { + payload.clearClientSecret = true; + } + + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/security/oidc', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Failed to save OIDC settings (${response.status})`); + } + + const updated = (await response.json()) as OIDCConfigResponse; + setConfig(updated); + resetForm(updated); + notificationStore.success('OIDC settings updated'); + props.onConfigUpdated?.(updated); + } catch (error) { + console.error('[OIDCPanel] Failed to save config:', error); + notificationStore.error('Failed to save OIDC settings'); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+
+ + + +
+ + { + setForm('enabled', event.currentTarget.checked); + }} + disabled={isEnvLocked() || loading() || saving()} + containerClass="items-center gap-2" + label={{form.enabled ? 'Enabled' : 'Disabled'}} + /> +
+
+ + +
+ + Loading OIDC settings... +
+
+ + + +
+ Managed by environment variables: OIDC settings are currently defined through environment variables. Edit the deployment configuration to make changes. +
+
+ +
+
+ + setForm('issuerUrl', event.currentTarget.value)} + placeholder="https://login.example.com/realms/pulse" + class={controlClass()} + disabled={isEnvLocked() || saving()} + required + /> +

Base issuer URL from your OIDC provider configuration.

+
+
+ + setForm('clientId', event.currentTarget.value)} + placeholder="pulse-client" + class={controlClass()} + disabled={isEnvLocked() || saving()} + required + /> +
+
+
+ + + + +
+ { + setForm('clientSecret', event.currentTarget.value); + if (event.currentTarget.value.trim() !== '') { + setForm('clearSecret', false); + } + }} + placeholder={config()?.clientSecretSet ? '•••••••• (leave blank to keep existing)' : 'Enter client secret'} + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

Leave blank to keep the existing secret. Use "Clear" to remove it from storage.

+
+
+ + setForm('redirectUrl', event.currentTarget.value)} + placeholder={config()?.defaultRedirect || ''} + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

If left blank, Pulse will use {config()?.defaultRedirect}.

+
+
+ +
+ + + +
+
+ + setForm('scopes', event.currentTarget.value)} + placeholder="openid profile email" + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

Space-separated list of scopes requested during login.

+
+
+ + setForm('usernameClaim', event.currentTarget.value)} + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

Claim used to populate the Pulse username (default: preferred_username).

+
+
+ + setForm('emailClaim', event.currentTarget.value)} + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +
+
+ + setForm('groupsClaim', event.currentTarget.value)} + class={controlClass()} + disabled={isEnvLocked() || saving()} + /> +

Optional claim that lists group memberships. Used for group restrictions.

+
+
+ +