mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: add OIDC single sign-on
This commit is contained in:
30
dev/oidc/dex-config.yaml
Normal file
30
dev/oidc/dex-config.yaml
Normal file
@@ -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
|
||||
@@ -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<string, boolean>;
|
||||
}
|
||||
|
||||
export const Login: Component<LoginProps> = (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<SecurityStatus | null>(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
|
||||
@@ -33,6 +66,24 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
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<LoginProps> = (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<LoginProps> = (props) => {
|
||||
>
|
||||
<Show
|
||||
when={authStatus()?.hasAuthentication === false}
|
||||
fallback={<LoginForm {...{ username, setUsername, password, setPassword, error, loading, handleSubmit }} />}
|
||||
fallback={<LoginForm {...{ username, setUsername, password, setPassword, error, loading, handleSubmit, supportsOIDC, startOidcLogin, oidcLoading, oidcError, oidcMessage }} />}
|
||||
>
|
||||
<Suspense fallback={
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900">
|
||||
@@ -187,8 +288,13 @@ const LoginForm: Component<{
|
||||
error: () => string;
|
||||
loading: () => boolean;
|
||||
handleSubmit: (e: Event) => void;
|
||||
supportsOIDC: () => boolean;
|
||||
startOidcLogin: () => void | Promise<void>;
|
||||
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 (
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
@@ -212,6 +318,49 @@ const LoginForm: Component<{
|
||||
</p>
|
||||
</div>
|
||||
<form class="mt-8 space-y-6 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg rounded-lg p-8 shadow-xl animate-slide-up" onSubmit={handleSubmit}>
|
||||
<Show when={supportsOIDC()}>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full inline-flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-blue-500 text-blue-600 hover:bg-blue-50 transition dark:border-blue-400 dark:text-blue-200 dark:hover:bg-blue-900/40 ${oidcLoading() ? 'opacity-75 cursor-wait' : ''}`}
|
||||
disabled={oidcLoading()}
|
||||
onClick={() => startOidcLogin()}
|
||||
>
|
||||
<Show
|
||||
when={!oidcLoading()}
|
||||
fallback={
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Redirecting…
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9" />
|
||||
</svg>
|
||||
Continue with Single Sign-On
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={oidcError()}>
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-900/40 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-600 dark:text-red-300">
|
||||
{oidcError()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={oidcMessage()}>
|
||||
<div class="rounded-md bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 px-3 py-2 text-sm text-green-600 dark:text-green-300">
|
||||
{oidcMessage()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
<span class="text-xs uppercase tracking-wide text-gray-400 dark:text-gray-500">or</span>
|
||||
<span class="flex-1 h-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<p class="text-xs text-center text-gray-500 dark:text-gray-400">Use your admin credentials to sign in below.</p>
|
||||
</div>
|
||||
</Show>
|
||||
<input type="hidden" name="remember" value="true" />
|
||||
<div class="space-y-4">
|
||||
<div class="relative">
|
||||
|
||||
417
frontend-modern/src/components/Settings/OIDCPanel.tsx
Normal file
417
frontend-modern/src/components/Settings/OIDCPanel.tsx
Normal file
@@ -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<string, boolean>;
|
||||
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> = (props) => {
|
||||
const [config, setConfig] = createSignal<OIDCConfigResponse | null>(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<string, unknown> = {
|
||||
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 (
|
||||
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9" />
|
||||
</svg>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title="Single sign-on (OIDC)"
|
||||
description="Connect Pulse to your identity provider"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(event) => {
|
||||
setForm('enabled', event.currentTarget.checked);
|
||||
}}
|
||||
disabled={isEnvLocked() || loading() || saving()}
|
||||
containerClass="items-center gap-2"
|
||||
label={<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{form.enabled ? 'Enabled' : 'Disabled'}</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form class="p-6 space-y-5" onSubmit={handleSave}>
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Loading OIDC settings...
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!loading()}>
|
||||
<Show when={isEnvLocked()}>
|
||||
<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded p-3 text-xs text-amber-800 dark:text-amber-200">
|
||||
<strong>Managed by environment variables:</strong> OIDC settings are currently defined through environment variables. Edit the deployment configuration to make changes.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.issuerUrl}
|
||||
onInput={(event) => setForm('issuerUrl', event.currentTarget.value)}
|
||||
placeholder="https://login.example.com/realms/pulse"
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
required
|
||||
/>
|
||||
<p class={formHelpText}>Base issuer URL from your OIDC provider configuration.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.clientId}
|
||||
onInput={(event) => setForm('clientId', event.currentTarget.value)}
|
||||
placeholder="pulse-client"
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class={labelClass('mb-0')}>Client secret</label>
|
||||
<Show when={config()?.clientSecretSet}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-blue-600 hover:underline dark:text-blue-300"
|
||||
onClick={() => {
|
||||
if (!isEnvLocked() && !saving()) {
|
||||
setForm('clientSecret', '');
|
||||
setForm('clearSecret', true);
|
||||
notificationStore.info('Client secret will be cleared on save', 2500);
|
||||
}
|
||||
}}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
>
|
||||
Clear stored secret
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onInput={(event) => {
|
||||
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()}
|
||||
/>
|
||||
<p class={formHelpText}>Leave blank to keep the existing secret. Use "Clear" to remove it from storage.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Redirect URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.redirectUrl}
|
||||
onInput={(event) => setForm('redirectUrl', event.currentTarget.value)}
|
||||
placeholder={config()?.defaultRedirect || ''}
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>If left blank, Pulse will use {config()?.defaultRedirect}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-blue-600 hover:underline dark:text-blue-300"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen())}
|
||||
>
|
||||
{advancedOpen() ? 'Hide advanced OIDC options' : 'Show advanced OIDC options'}
|
||||
</button>
|
||||
|
||||
<Show when={advancedOpen()}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Scopes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.scopes}
|
||||
onInput={(event) => setForm('scopes', event.currentTarget.value)}
|
||||
placeholder="openid profile email"
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Space-separated list of scopes requested during login.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Username claim</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.usernameClaim}
|
||||
onInput={(event) => setForm('usernameClaim', event.currentTarget.value)}
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Claim used to populate the Pulse username (default: preferred_username).</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Email claim</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.emailClaim}
|
||||
onInput={(event) => setForm('emailClaim', event.currentTarget.value)}
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Groups claim</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.groupsClaim}
|
||||
onInput={(event) => setForm('groupsClaim', event.currentTarget.value)}
|
||||
class={controlClass()}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Optional claim that lists group memberships. Used for group restrictions.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Allowed groups</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.allowedGroups}
|
||||
onInput={(event) => setForm('allowedGroups', event.currentTarget.value)}
|
||||
placeholder="admin, sso-admins"
|
||||
class={controlClass('min-h-[70px]')}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Comma or space separated values. Leave empty to allow any group.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Allowed domains</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.allowedDomains}
|
||||
onInput={(event) => setForm('allowedDomains', event.currentTarget.value)}
|
||||
placeholder="example.com, partner.io"
|
||||
class={controlClass('min-h-[70px]')}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Restrict access to email domains (without @). Leave empty to allow all.</p>
|
||||
</div>
|
||||
<div class={formField}>
|
||||
<label class={labelClass()}>Allowed email addresses</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.allowedEmails}
|
||||
onInput={(event) => setForm('allowedEmails', event.currentTarget.value)}
|
||||
placeholder="admin@example.com"
|
||||
class={controlClass('min-h-[70px]')}
|
||||
disabled={isEnvLocked() || saving()}
|
||||
/>
|
||||
<p class={formHelpText}>Optional allowlist of specific emails.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 pt-4">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Redirect URL registered with your IdP must match Pulse: {config()?.defaultRedirect || ''}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
onClick={() => resetForm(config())}
|
||||
disabled={saving() || loading()}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving() || loading() || isEnvLocked()}
|
||||
>
|
||||
{saving() ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OIDCPanel;
|
||||
3
go.mod
3
go.mod
@@ -5,17 +5,20 @@ go 1.24.0
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.15.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/term v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
14
go.sum
14
go.sum
@@ -1,8 +1,16 @@
|
||||
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -17,6 +25,8 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
@@ -26,8 +36,12 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -195,11 +195,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||
}
|
||||
}
|
||||
|
||||
// If no auth is configured at all, allow access
|
||||
// If no auth is configured at all, allow access unless OIDC is enabled
|
||||
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken == "" && cfg.ProxyAuthSecret == "" {
|
||||
if cfg.OIDC != nil && cfg.OIDC.Enabled {
|
||||
log.Debug().Msg("OIDC enabled without local credentials, authentication required")
|
||||
} else {
|
||||
log.Debug().Msg("No auth configured, allowing access")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// API-only mode: when only API token is configured (no password auth)
|
||||
// Allow read-only endpoints for the UI to work
|
||||
@@ -461,7 +465,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
|
||||
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`,
|
||||
attempts, remaining, maxFailedAttempts)))
|
||||
} else {
|
||||
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","locked":true,"message":"Account locked for 15 minutes"}`,)))
|
||||
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","locked":true,"message":"Account locked for 15 minutes"}`)))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -57,6 +57,11 @@ func getFrontendDevProxy() (*httputil.ReverseProxy, error) {
|
||||
|
||||
// getFrontendFS returns the embedded frontend filesystem
|
||||
func getFrontendFS() (http.FileSystem, error) {
|
||||
if dir := strings.TrimSpace(os.Getenv("PULSE_FRONTEND_DIR")); dir != "" {
|
||||
log.Warn().Str("frontend_dir", dir).Msg("Serving frontend from filesystem override")
|
||||
return http.Dir(dir), nil
|
||||
}
|
||||
|
||||
// Strip the prefix to serve files from root
|
||||
fsys, err := fs.Sub(embeddedFrontend, "frontend-modern/dist")
|
||||
if err != nil {
|
||||
|
||||
365
internal/api/oidc_handlers.go
Normal file
365
internal/api/oidc_handlers.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (r *Router) handleOIDCLogin(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := r.ensureOIDCConfig()
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "oidc_disabled", "OIDC authentication is not enabled", nil)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := r.getOIDCService(req.Context())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to initialise OIDC service")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "oidc_init_failed", "OIDC provider is unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
ReturnTo string `json:"returnTo"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request payload", nil)
|
||||
return
|
||||
}
|
||||
|
||||
returnTo := sanitizeOIDCReturnTo(payload.ReturnTo)
|
||||
|
||||
state, entry, err := service.newStateEntry(returnTo)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create OIDC state entry")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "oidc_state_error", "Unable to start OIDC login", nil)
|
||||
return
|
||||
}
|
||||
|
||||
authURL := service.authCodeURL(state, entry)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"authorizationUrl": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := r.ensureOIDCConfig()
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
http.Error(w, "OIDC is not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
service, err := r.getOIDCService(req.Context())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to initialise OIDC service for callback")
|
||||
r.redirectOIDCError(w, req, "", "oidc_init_failed")
|
||||
return
|
||||
}
|
||||
|
||||
query := req.URL.Query()
|
||||
if errParam := query.Get("error"); errParam != "" {
|
||||
log.Warn().Str("error", errParam).Msg("OIDC provider returned error")
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Provider error: "+errParam)
|
||||
r.redirectOIDCError(w, req, "", errParam)
|
||||
return
|
||||
}
|
||||
|
||||
state := query.Get("state")
|
||||
if state == "" {
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing state parameter")
|
||||
r.redirectOIDCError(w, req, "", "missing_state")
|
||||
return
|
||||
}
|
||||
|
||||
entry, ok := service.consumeState(state)
|
||||
if !ok {
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Invalid or expired state")
|
||||
r.redirectOIDCError(w, req, "", "invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing authorization code")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_code")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := service.exchangeCode(ctx, code, entry)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("OIDC code exchange failed")
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Code exchange failed")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing ID token")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_id_token")
|
||||
return
|
||||
}
|
||||
|
||||
idToken, err := service.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to verify ID token")
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "ID token verification failed")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "invalid_id_token")
|
||||
return
|
||||
}
|
||||
|
||||
claims := make(map[string]any)
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to parse ID token claims")
|
||||
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Invalid token claims")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "invalid_claims")
|
||||
return
|
||||
}
|
||||
|
||||
username := extractStringClaim(claims, cfg.UsernameClaim)
|
||||
email := extractStringClaim(claims, cfg.EmailClaim)
|
||||
if username == "" {
|
||||
username = email
|
||||
}
|
||||
if username == "" {
|
||||
username = extractStringClaim(claims, "name")
|
||||
}
|
||||
if username == "" {
|
||||
username = idToken.Subject
|
||||
}
|
||||
|
||||
if len(cfg.AllowedEmails) > 0 && !matchesValue(email, cfg.AllowedEmails) {
|
||||
LogAuditEvent("oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email not permitted")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "email_restricted")
|
||||
return
|
||||
}
|
||||
|
||||
if len(cfg.AllowedDomains) > 0 && !matchesDomain(email, cfg.AllowedDomains) {
|
||||
LogAuditEvent("oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email domain restricted")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "domain_restricted")
|
||||
return
|
||||
}
|
||||
|
||||
if len(cfg.AllowedGroups) > 0 {
|
||||
groups := extractStringSliceClaim(claims, cfg.GroupsClaim)
|
||||
if !intersects(groups, cfg.AllowedGroups) {
|
||||
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Group restriction failed")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "group_restricted")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.establishSession(w, req, username); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to establish session after OIDC login")
|
||||
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Session creation failed")
|
||||
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
|
||||
return
|
||||
}
|
||||
|
||||
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, true, "OIDC login success")
|
||||
|
||||
target := entry.ReturnTo
|
||||
if target == "" {
|
||||
target = "/"
|
||||
}
|
||||
target = addQueryParam(target, "oidc", "success")
|
||||
http.Redirect(w, req, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func (r *Router) getOIDCService(ctx context.Context) (*OIDCService, error) {
|
||||
cfg := r.ensureOIDCConfig()
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
return nil, errors.New("oidc disabled")
|
||||
}
|
||||
|
||||
r.oidcMu.Lock()
|
||||
defer r.oidcMu.Unlock()
|
||||
|
||||
if r.oidcService != nil && r.oidcService.Matches(cfg) {
|
||||
return r.oidcService, nil
|
||||
}
|
||||
|
||||
service, err := NewOIDCService(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.oidcService = service
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func sanitizeOIDCReturnTo(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "//") {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (r *Router) redirectOIDCError(w http.ResponseWriter, req *http.Request, returnTo string, code string) {
|
||||
target := returnTo
|
||||
if target == "" {
|
||||
target = "/"
|
||||
}
|
||||
target = addQueryParam(target, "oidc", "error")
|
||||
if code != "" {
|
||||
target = addQueryParam(target, "oidc_error", code)
|
||||
}
|
||||
|
||||
http.Redirect(w, req, target, http.StatusFound)
|
||||
}
|
||||
|
||||
func addQueryParam(path, key, value string) string {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set(key, value)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func extractStringClaim(claims map[string]any, key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
value, ok := claims[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case []string:
|
||||
if len(v) > 0 {
|
||||
return strings.TrimSpace(v[0])
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractStringSliceClaim(claims map[string]any, key string) []string {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
value, ok := claims[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if str, ok := item.(string); ok {
|
||||
out = append(out, str)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
// Split on commas or spaces
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||||
return r == ',' || r == ' '
|
||||
})
|
||||
return parts
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func matchesValue(candidate string, allowed []string) bool {
|
||||
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||
if candidate == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range allowed {
|
||||
if strings.ToLower(strings.TrimSpace(item)) == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesDomain(email string, allowed []string) bool {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at == -1 || at == len(email)-1 {
|
||||
return false
|
||||
}
|
||||
domain := email[at+1:]
|
||||
for _, item := range allowed {
|
||||
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(item), "@"))
|
||||
if normalized != "" && domain == normalized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func intersects(values []string, allowed []string) bool {
|
||||
if len(values) == 0 || len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, item := range allowed {
|
||||
allowedSet[strings.ToLower(strings.TrimSpace(item))] = struct{}{}
|
||||
}
|
||||
for _, val := range values {
|
||||
if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(val))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Router) ensureOIDCConfig() *config.OIDCConfig {
|
||||
if r.config.OIDC == nil {
|
||||
r.config.OIDC = config.NewOIDCConfig()
|
||||
r.config.OIDC.ApplyDefaults(r.config.PublicURL)
|
||||
}
|
||||
return r.config.OIDC
|
||||
}
|
||||
220
internal/api/oidc_service.go
Normal file
220
internal/api/oidc_service.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// oidcStateTTL defines how long we accept OIDC login attempts before expiring the state entry.
|
||||
const oidcStateTTL = 10 * time.Minute
|
||||
|
||||
// OIDCService caches provider metadata and manages transient state for authorization flows.
|
||||
type OIDCService struct {
|
||||
snapshot oidcSnapshot
|
||||
provider *oidc.Provider
|
||||
oauth2Cfg *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
stateStore *oidcStateStore
|
||||
}
|
||||
|
||||
type oidcSnapshot struct {
|
||||
issuer string
|
||||
clientID string
|
||||
clientSecret string
|
||||
redirectURL string
|
||||
scopes []string
|
||||
}
|
||||
|
||||
// NewOIDCService fetches provider metadata and prepares helper structures.
|
||||
func NewOIDCService(ctx context.Context, cfg *config.OIDCConfig) (*OIDCService, error) {
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
return nil, errors.New("oidc is not enabled")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to discover OIDC provider: %w", err)
|
||||
}
|
||||
|
||||
oauth2Cfg := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: append([]string{}, cfg.Scopes...),
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
|
||||
|
||||
snapshot := oidcSnapshot{
|
||||
issuer: cfg.IssuerURL,
|
||||
clientID: cfg.ClientID,
|
||||
clientSecret: cfg.ClientSecret,
|
||||
redirectURL: cfg.RedirectURL,
|
||||
scopes: append([]string{}, cfg.Scopes...),
|
||||
}
|
||||
|
||||
service := &OIDCService{
|
||||
snapshot: snapshot,
|
||||
provider: provider,
|
||||
oauth2Cfg: oauth2Cfg,
|
||||
verifier: verifier,
|
||||
stateStore: newOIDCStateStore(),
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Matches checks whether the cached configuration matches the provided settings.
|
||||
func (s *OIDCService) Matches(cfg *config.OIDCConfig) bool {
|
||||
if s == nil || cfg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if s.snapshot.issuer != cfg.IssuerURL {
|
||||
return false
|
||||
}
|
||||
if s.snapshot.clientID != cfg.ClientID {
|
||||
return false
|
||||
}
|
||||
if s.snapshot.clientSecret != cfg.ClientSecret {
|
||||
return false
|
||||
}
|
||||
if s.snapshot.redirectURL != cfg.RedirectURL {
|
||||
return false
|
||||
}
|
||||
if len(s.snapshot.scopes) != len(cfg.Scopes) {
|
||||
return false
|
||||
}
|
||||
for i, scope := range s.snapshot.scopes {
|
||||
if scope != cfg.Scopes[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *OIDCService) newStateEntry(returnTo string) (string, *oidcStateEntry, error) {
|
||||
state, err := generateRandomURLString(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
nonce, err := generateRandomURLString(32)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
codeVerifier, codeChallenge, err := generatePKCEPair()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
entry := &oidcStateEntry{
|
||||
Nonce: nonce,
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
ReturnTo: returnTo,
|
||||
ExpiresAt: time.Now().Add(oidcStateTTL),
|
||||
}
|
||||
|
||||
s.stateStore.Put(state, entry)
|
||||
return state, entry, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) consumeState(state string) (*oidcStateEntry, bool) {
|
||||
return s.stateStore.Consume(state)
|
||||
}
|
||||
|
||||
func (s *OIDCService) authCodeURL(state string, entry *oidcStateEntry) string {
|
||||
opts := []oauth2.AuthCodeOption{oidc.Nonce(entry.Nonce)}
|
||||
if entry.CodeChallenge != "" {
|
||||
opts = append(opts,
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
oauth2.SetAuthURLParam("code_challenge", entry.CodeChallenge),
|
||||
)
|
||||
}
|
||||
return s.oauth2Cfg.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
func (s *OIDCService) exchangeCode(ctx context.Context, code string, entry *oidcStateEntry) (*oauth2.Token, error) {
|
||||
opts := []oauth2.AuthCodeOption{}
|
||||
if entry.CodeVerifier != "" {
|
||||
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", entry.CodeVerifier))
|
||||
}
|
||||
return s.oauth2Cfg.Exchange(ctx, code, opts...)
|
||||
}
|
||||
|
||||
// oidcStateStore keeps short-lived authorization state tokens.
|
||||
type oidcStateStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*oidcStateEntry
|
||||
}
|
||||
|
||||
type oidcStateEntry struct {
|
||||
Nonce string
|
||||
CodeVerifier string
|
||||
CodeChallenge string
|
||||
ReturnTo string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func newOIDCStateStore() *oidcStateStore {
|
||||
return &oidcStateStore{entries: make(map[string]*oidcStateEntry)}
|
||||
}
|
||||
|
||||
func (s *oidcStateStore) Put(state string, entry *oidcStateEntry) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.entries[state] = entry
|
||||
}
|
||||
|
||||
func (s *oidcStateStore) Consume(state string) (*oidcStateEntry, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, exists := s.entries[state]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
delete(s.entries, state)
|
||||
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry, true
|
||||
}
|
||||
|
||||
func generateRandomURLString(size int) (string, error) {
|
||||
bytes := make([]byte, size)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func generatePKCEPair() (verifier string, challenge string, err error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
verifier = base64.RawURLEncoding.EncodeToString(buf)
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||
@@ -33,9 +34,10 @@ type Router struct {
|
||||
updateManager *updates.Manager
|
||||
exportLimiter *RateLimiter
|
||||
persistence *config.ConfigPersistence
|
||||
oidcMu sync.Mutex
|
||||
oidcService *OIDCService
|
||||
}
|
||||
|
||||
|
||||
// NewRouter creates a new router instance
|
||||
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error) http.Handler {
|
||||
// Initialize persistent session and CSRF stores
|
||||
@@ -77,7 +79,6 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
|
||||
return handler
|
||||
}
|
||||
|
||||
|
||||
// setupRoutes configures all routes
|
||||
func (r *Router) setupRoutes() {
|
||||
// Create handlers
|
||||
@@ -190,6 +191,9 @@ func (r *Router) setupRoutes() {
|
||||
r.mux.HandleFunc("/api/logout", r.handleLogout)
|
||||
r.mux.HandleFunc("/api/login", r.handleLogin)
|
||||
r.mux.HandleFunc("/api/security/reset-lockout", r.handleResetLockout)
|
||||
r.mux.HandleFunc("/api/security/oidc", RequireAdmin(r.config, r.handleOIDCConfig))
|
||||
r.mux.HandleFunc("/api/oidc/login", r.handleOIDCLogin)
|
||||
r.mux.HandleFunc(config.DefaultOIDCCallbackPath, r.handleOIDCCallback)
|
||||
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -215,10 +219,14 @@ func (r *Router) setupRoutes() {
|
||||
|
||||
// Check for basic auth configuration
|
||||
// Check both environment variables and loaded config
|
||||
oidcCfg := r.ensureOIDCConfig()
|
||||
hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" ||
|
||||
os.Getenv("REQUIRE_AUTH") == "true" ||
|
||||
r.config.AuthUser != "" ||
|
||||
r.config.AuthPass != ""
|
||||
r.config.AuthPass != "" ||
|
||||
(oidcCfg != nil && oidcCfg.Enabled) ||
|
||||
r.config.APIToken != "" ||
|
||||
r.config.ProxyAuthSecret != ""
|
||||
|
||||
// Check if .env file exists but hasn't been loaded yet (pending restart)
|
||||
configuredButPendingRestart := false
|
||||
@@ -274,10 +282,15 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
requiresAuth := r.config.APIToken != "" ||
|
||||
(r.config.AuthUser != "" && r.config.AuthPass != "") ||
|
||||
(r.config.OIDC != nil && r.config.OIDC.Enabled) ||
|
||||
r.config.ProxyAuthSecret != ""
|
||||
|
||||
status := map[string]interface{}{
|
||||
"apiTokenConfigured": r.config.APIToken != "",
|
||||
"apiTokenHint": apiTokenHint,
|
||||
"requiresAuth": r.config.APIToken != "",
|
||||
"requiresAuth": requiresAuth,
|
||||
"exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
|
||||
"unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
|
||||
"hasAuthentication": hasAuthentication,
|
||||
@@ -294,6 +307,16 @@ func (r *Router) setupRoutes() {
|
||||
"proxyAuthUsername": proxyAuthUsername,
|
||||
"proxyAuthIsAdmin": proxyAuthIsAdmin,
|
||||
}
|
||||
|
||||
if oidcCfg != nil {
|
||||
status["oidcEnabled"] = oidcCfg.Enabled
|
||||
status["oidcIssuer"] = oidcCfg.IssuerURL
|
||||
status["oidcClientId"] = oidcCfg.ClientID
|
||||
if len(oidcCfg.EnvOverrides) > 0 {
|
||||
status["oidcEnvOverrides"] = oidcCfg.EnvOverrides
|
||||
}
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(status)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -790,7 +813,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if we need authentication
|
||||
needsAuth := true
|
||||
|
||||
@@ -851,6 +873,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
"/api/security/status",
|
||||
"/api/version",
|
||||
"/api/login", // Add login endpoint as public
|
||||
"/api/oidc/login",
|
||||
config.DefaultOIDCCallbackPath,
|
||||
}
|
||||
|
||||
// Also allow static assets without auth (JS, CSS, etc)
|
||||
@@ -957,7 +981,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
}), allowEmbedding, allowedEmbedOrigins).ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
|
||||
// handleHealth handles health check requests
|
||||
func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
@@ -1229,7 +1252,6 @@ PULSE_AUTH_PASS='%s'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// handleLogout handles logout requests
|
||||
func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
@@ -1282,6 +1304,45 @@ func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Router) establishSession(w http.ResponseWriter, req *http.Request, username string) error {
|
||||
token := generateSessionToken()
|
||||
if token == "" {
|
||||
return fmt.Errorf("failed to generate session token")
|
||||
}
|
||||
|
||||
userAgent := req.Header.Get("User-Agent")
|
||||
clientIP := GetClientIP(req)
|
||||
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP)
|
||||
|
||||
if username != "" {
|
||||
TrackUserSession(username, token)
|
||||
}
|
||||
|
||||
csrfToken := generateCSRFToken(token)
|
||||
isSecure, sameSitePolicy := getCookieSettings(req)
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pulse_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure,
|
||||
SameSite: sameSitePolicy,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pulse_csrf",
|
||||
Value: csrfToken,
|
||||
Path: "/",
|
||||
Secure: isSecure,
|
||||
SameSite: sameSitePolicy,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLogin handles login requests and provides detailed feedback about lockouts
|
||||
func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
@@ -1899,7 +1960,6 @@ func (r *Router) handleConfig(w http.ResponseWriter, req *http.Request) {
|
||||
json.NewEncoder(w).Encode(config)
|
||||
}
|
||||
|
||||
|
||||
// handleBackups handles backup requests
|
||||
func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
@@ -2228,7 +2288,6 @@ func (r *Router) handleSimpleStats(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
|
||||
// handleSocketIO handles socket.io requests
|
||||
func (r *Router) handleSocketIO(w http.ResponseWriter, req *http.Request) {
|
||||
// For socket.io.js, redirect to CDN
|
||||
@@ -2291,5 +2350,3 @@ func (r *Router) forwardUpdateProgress() {
|
||||
Msg("Update progress")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
157
internal/api/security_oidc.go
Normal file
157
internal/api/security_oidc.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// handleOIDCConfig exposes and updates the OIDC configuration.
|
||||
func (r *Router) handleOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.handleGetOIDCConfig(w, req)
|
||||
case http.MethodPut:
|
||||
r.handleUpdateOIDCConfig(w, req)
|
||||
default:
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET and PUT are supported", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleGetOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||
cfg := r.ensureOIDCConfig()
|
||||
|
||||
response := makeOIDCResponse(cfg, r.config.PublicURL)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode OIDC configuration response")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleUpdateOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||
cfg := r.ensureOIDCConfig()
|
||||
|
||||
if len(cfg.EnvOverrides) > 0 {
|
||||
writeErrorResponse(w, http.StatusConflict, "oidc_locked", "OIDC settings are managed via environment variables and cannot be changed at runtime", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IssuerURL string `json:"issuerUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret *string `json:"clientSecret,omitempty"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UsernameClaim string `json:"usernameClaim"`
|
||||
EmailClaim string `json:"emailClaim"`
|
||||
GroupsClaim string `json:"groupsClaim"`
|
||||
AllowedGroups []string `json:"allowedGroups"`
|
||||
AllowedDomains []string `json:"allowedDomains"`
|
||||
AllowedEmails []string `json:"allowedEmails"`
|
||||
ClearClientSecret bool `json:"clearClientSecret"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request payload", nil)
|
||||
return
|
||||
}
|
||||
|
||||
updated := &config.OIDCConfig{
|
||||
Enabled: payload.Enabled,
|
||||
IssuerURL: strings.TrimSpace(payload.IssuerURL),
|
||||
ClientID: strings.TrimSpace(payload.ClientID),
|
||||
RedirectURL: strings.TrimSpace(payload.RedirectURL),
|
||||
Scopes: append([]string{}, payload.Scopes...),
|
||||
UsernameClaim: strings.TrimSpace(payload.UsernameClaim),
|
||||
EmailClaim: strings.TrimSpace(payload.EmailClaim),
|
||||
GroupsClaim: strings.TrimSpace(payload.GroupsClaim),
|
||||
AllowedGroups: append([]string{}, payload.AllowedGroups...),
|
||||
AllowedDomains: append([]string{}, payload.AllowedDomains...),
|
||||
AllowedEmails: append([]string{}, payload.AllowedEmails...),
|
||||
EnvOverrides: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Preserve existing secret unless explicitly changed.
|
||||
updated.ClientSecret = cfg.ClientSecret
|
||||
if payload.ClearClientSecret {
|
||||
updated.ClientSecret = ""
|
||||
}
|
||||
if payload.ClientSecret != nil {
|
||||
updated.ClientSecret = strings.TrimSpace(*payload.ClientSecret)
|
||||
}
|
||||
|
||||
updated.ApplyDefaults(r.config.PublicURL)
|
||||
|
||||
if err := updated.Validate(); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.SaveOIDCConfig(updated); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to persist OIDC configuration")
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save OIDC settings", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Update in-memory configuration for immediate effect.
|
||||
r.config.OIDC = updated
|
||||
|
||||
response := makeOIDCResponse(updated, r.config.PublicURL)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode OIDC configuration response")
|
||||
}
|
||||
}
|
||||
|
||||
type oidcResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IssuerURL string `json:"issuerUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes"`
|
||||
UsernameClaim string `json:"usernameClaim"`
|
||||
EmailClaim string `json:"emailClaim"`
|
||||
GroupsClaim string `json:"groupsClaim"`
|
||||
AllowedGroups []string `json:"allowedGroups"`
|
||||
AllowedDomains []string `json:"allowedDomains"`
|
||||
AllowedEmails []string `json:"allowedEmails"`
|
||||
ClientSecretSet bool `json:"clientSecretSet"`
|
||||
DefaultRedirect string `json:"defaultRedirect"`
|
||||
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
|
||||
}
|
||||
|
||||
func makeOIDCResponse(cfg *config.OIDCConfig, publicURL string) oidcResponse {
|
||||
if cfg == nil {
|
||||
cfg = config.NewOIDCConfig()
|
||||
cfg.ApplyDefaults(publicURL)
|
||||
}
|
||||
|
||||
resp := oidcResponse{
|
||||
Enabled: cfg.Enabled,
|
||||
IssuerURL: cfg.IssuerURL,
|
||||
ClientID: cfg.ClientID,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Scopes: append([]string{}, cfg.Scopes...),
|
||||
UsernameClaim: cfg.UsernameClaim,
|
||||
EmailClaim: cfg.EmailClaim,
|
||||
GroupsClaim: cfg.GroupsClaim,
|
||||
AllowedGroups: append([]string{}, cfg.AllowedGroups...),
|
||||
AllowedDomains: append([]string{}, cfg.AllowedDomains...),
|
||||
AllowedEmails: append([]string{}, cfg.AllowedEmails...),
|
||||
ClientSecretSet: cfg.ClientSecret != "",
|
||||
DefaultRedirect: config.DefaultRedirectURL(publicURL),
|
||||
}
|
||||
|
||||
if len(cfg.EnvOverrides) > 0 {
|
||||
resp.EnvOverrides = make(map[string]bool, len(cfg.EnvOverrides))
|
||||
for k, v := range cfg.EnvOverrides {
|
||||
resp.EnvOverrides[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -100,6 +100,9 @@ type Config struct {
|
||||
ProxyAuthRoleSeparator string `envconfig:"PROXY_AUTH_ROLE_SEPARATOR" default:"|"`
|
||||
ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"`
|
||||
ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"`
|
||||
|
||||
// OIDC configuration
|
||||
OIDC *OIDCConfig `json:"-"`
|
||||
// HTTPS/TLS settings
|
||||
HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"`
|
||||
TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""`
|
||||
@@ -221,6 +224,7 @@ func Load() (*Config, error) {
|
||||
DiscoveryEnabled: true,
|
||||
DiscoverySubnet: "auto",
|
||||
EnvOverrides: make(map[string]bool),
|
||||
OIDC: NewOIDCConfig(),
|
||||
}
|
||||
|
||||
// Initialize persistence
|
||||
@@ -287,6 +291,12 @@ func Load() (*Config, error) {
|
||||
log.Warn().Err(err).Msg("Failed to create default system.json")
|
||||
}
|
||||
}
|
||||
|
||||
if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil {
|
||||
cfg.OIDC = oidcSettings
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to load OIDC configuration")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure PBS polling interval has default if not set
|
||||
@@ -372,6 +382,47 @@ func Load() (*Config, error) {
|
||||
log.Info().Str("url", logoutURL).Msg("Proxy auth logout URL configured")
|
||||
}
|
||||
}
|
||||
|
||||
oidcEnv := make(map[string]string)
|
||||
if val := os.Getenv("OIDC_ENABLED"); val != "" {
|
||||
oidcEnv["OIDC_ENABLED"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_ISSUER_URL"); val != "" {
|
||||
oidcEnv["OIDC_ISSUER_URL"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_CLIENT_ID"); val != "" {
|
||||
oidcEnv["OIDC_CLIENT_ID"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_CLIENT_SECRET"); val != "" {
|
||||
oidcEnv["OIDC_CLIENT_SECRET"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_REDIRECT_URL"); val != "" {
|
||||
oidcEnv["OIDC_REDIRECT_URL"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_SCOPES"); val != "" {
|
||||
oidcEnv["OIDC_SCOPES"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_USERNAME_CLAIM"); val != "" {
|
||||
oidcEnv["OIDC_USERNAME_CLAIM"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_EMAIL_CLAIM"); val != "" {
|
||||
oidcEnv["OIDC_EMAIL_CLAIM"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_GROUPS_CLAIM"); val != "" {
|
||||
oidcEnv["OIDC_GROUPS_CLAIM"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_ALLOWED_GROUPS"); val != "" {
|
||||
oidcEnv["OIDC_ALLOWED_GROUPS"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_ALLOWED_DOMAINS"); val != "" {
|
||||
oidcEnv["OIDC_ALLOWED_DOMAINS"] = val
|
||||
}
|
||||
if val := os.Getenv("OIDC_ALLOWED_EMAILS"); val != "" {
|
||||
oidcEnv["OIDC_ALLOWED_EMAILS"] = val
|
||||
}
|
||||
if len(oidcEnv) > 0 {
|
||||
cfg.OIDC.MergeFromEnv(oidcEnv)
|
||||
}
|
||||
if authUser := os.Getenv("PULSE_AUTH_USER"); authUser != "" {
|
||||
cfg.AuthUser = authUser
|
||||
log.Info().Msg("Overriding auth user from env var")
|
||||
@@ -477,6 +528,8 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
cfg.OIDC.ApplyDefaults(cfg.PublicURL)
|
||||
|
||||
// Set log level
|
||||
switch cfg.LogLevel {
|
||||
case "debug":
|
||||
@@ -529,6 +582,23 @@ func SaveConfig(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveOIDCConfig persists OIDC settings using the shared config persistence layer.
|
||||
func SaveOIDCConfig(settings *OIDCConfig) error {
|
||||
if globalPersistence == nil {
|
||||
return fmt.Errorf("config persistence not initialized")
|
||||
}
|
||||
if settings == nil {
|
||||
return fmt.Errorf("oidc settings cannot be nil")
|
||||
}
|
||||
|
||||
clone := settings.Clone()
|
||||
if clone == nil {
|
||||
return fmt.Errorf("failed to clone oidc settings")
|
||||
}
|
||||
|
||||
return globalPersistence.SaveOIDCConfig(*clone)
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid
|
||||
func (c *Config) Validate() error {
|
||||
// Validate server settings
|
||||
@@ -580,6 +650,10 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
c.PBSInstances = validPBS
|
||||
|
||||
if err := c.OIDC.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
225
internal/config/oidc.go
Normal file
225
internal/config/oidc.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultOIDCScopes defines the scopes we request when none are provided.
|
||||
var defaultOIDCScopes = []string{"openid", "profile", "email"}
|
||||
|
||||
// DefaultOIDCCallbackPath is the path we expose for the OIDC redirect handler.
|
||||
const DefaultOIDCCallbackPath = "/api/oidc/callback"
|
||||
|
||||
// OIDCConfig captures configuration required to integrate with an OpenID Connect provider.
|
||||
type OIDCConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IssuerURL string `json:"issuerUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
UsernameClaim string `json:"usernameClaim,omitempty"`
|
||||
EmailClaim string `json:"emailClaim,omitempty"`
|
||||
GroupsClaim string `json:"groupsClaim,omitempty"`
|
||||
AllowedGroups []string `json:"allowedGroups,omitempty"`
|
||||
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
||||
AllowedEmails []string `json:"allowedEmails,omitempty"`
|
||||
EnvOverrides map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
// NewOIDCConfig returns an instance populated with sensible defaults.
|
||||
func NewOIDCConfig() *OIDCConfig {
|
||||
cfg := &OIDCConfig{}
|
||||
cfg.ApplyDefaults("")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the configuration.
|
||||
func (c *OIDCConfig) Clone() *OIDCConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := *c
|
||||
clone.Scopes = append([]string{}, c.Scopes...)
|
||||
clone.AllowedGroups = append([]string{}, c.AllowedGroups...)
|
||||
clone.AllowedDomains = append([]string{}, c.AllowedDomains...)
|
||||
clone.AllowedEmails = append([]string{}, c.AllowedEmails...)
|
||||
if c.EnvOverrides != nil {
|
||||
clone.EnvOverrides = make(map[string]bool, len(c.EnvOverrides))
|
||||
for k, v := range c.EnvOverrides {
|
||||
clone.EnvOverrides[k] = v
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// ApplyDefaults normalises the configuration and injects default values where needed.
|
||||
func (c *OIDCConfig) ApplyDefaults(publicURL string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(c.Scopes) == 0 {
|
||||
c.Scopes = append([]string{}, defaultOIDCScopes...)
|
||||
} else {
|
||||
c.Scopes = normaliseList(c.Scopes)
|
||||
}
|
||||
|
||||
if c.UsernameClaim = strings.TrimSpace(c.UsernameClaim); c.UsernameClaim == "" {
|
||||
c.UsernameClaim = "preferred_username"
|
||||
}
|
||||
if c.EmailClaim = strings.TrimSpace(c.EmailClaim); c.EmailClaim == "" {
|
||||
c.EmailClaim = "email"
|
||||
}
|
||||
c.GroupsClaim = strings.TrimSpace(c.GroupsClaim)
|
||||
|
||||
c.AllowedGroups = normaliseList(c.AllowedGroups)
|
||||
c.AllowedDomains = normaliseList(c.AllowedDomains)
|
||||
c.AllowedEmails = normaliseList(c.AllowedEmails)
|
||||
|
||||
if c.EnvOverrides == nil {
|
||||
c.EnvOverrides = make(map[string]bool)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.RedirectURL) == "" {
|
||||
c.RedirectURL = DefaultRedirectURL(publicURL)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRedirectURL builds a redirect URL using the provided public base URL.
|
||||
func DefaultRedirectURL(publicURL string) string {
|
||||
if strings.TrimSpace(publicURL) == "" {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimRight(publicURL, "/")
|
||||
return base + DefaultOIDCCallbackPath
|
||||
}
|
||||
|
||||
// Validate performs sanity checks and returns the first error encountered.
|
||||
func (c *OIDCConfig) Validate() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.IssuerURL) == "" {
|
||||
return fmt.Errorf("oidc issuer url is required when OIDC is enabled")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(c.IssuerURL); err != nil {
|
||||
return fmt.Errorf("invalid oidc issuer url: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.ClientID) == "" {
|
||||
return fmt.Errorf("oidc client id is required when OIDC is enabled")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.RedirectURL) == "" {
|
||||
return fmt.Errorf("oidc redirect url is required when OIDC is enabled")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(c.RedirectURL); err != nil {
|
||||
return fmt.Errorf("invalid oidc redirect url: %w", err)
|
||||
}
|
||||
|
||||
if len(c.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes must contain at least one entry")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normaliseList trims entries, removes blanks, and de-duplicates while preserving order.
|
||||
func normaliseList(values []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
if _, exists := seen[lower]; exists {
|
||||
continue
|
||||
}
|
||||
seen[lower] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseDelimited converts a delimiter-separated string into a clean slice.
|
||||
func parseDelimited(input string) []string {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Accept either comma or whitespace separation; replace commas with spaces then split.
|
||||
normalised := strings.ReplaceAll(input, ",", " ")
|
||||
parts := strings.Fields(normalised)
|
||||
return normaliseList(parts)
|
||||
}
|
||||
|
||||
// MergeFromEnv overrides config values with environment provided pairs.
|
||||
func (c *OIDCConfig) MergeFromEnv(env map[string]string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.EnvOverrides == nil {
|
||||
c.EnvOverrides = make(map[string]bool)
|
||||
}
|
||||
|
||||
if val, ok := env["OIDC_ENABLED"]; ok {
|
||||
c.Enabled = val == "true" || val == "1"
|
||||
c.EnvOverrides["enabled"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_ISSUER_URL"]; ok {
|
||||
c.IssuerURL = val
|
||||
c.EnvOverrides["issuerUrl"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_CLIENT_ID"]; ok {
|
||||
c.ClientID = val
|
||||
c.EnvOverrides["clientId"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_CLIENT_SECRET"]; ok {
|
||||
c.ClientSecret = val
|
||||
c.EnvOverrides["clientSecret"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_REDIRECT_URL"]; ok {
|
||||
c.RedirectURL = val
|
||||
c.EnvOverrides["redirectUrl"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_SCOPES"]; ok {
|
||||
c.Scopes = parseDelimited(val)
|
||||
c.EnvOverrides["scopes"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_USERNAME_CLAIM"]; ok {
|
||||
c.UsernameClaim = val
|
||||
c.EnvOverrides["usernameClaim"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_EMAIL_CLAIM"]; ok {
|
||||
c.EmailClaim = val
|
||||
c.EnvOverrides["emailClaim"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_GROUPS_CLAIM"]; ok {
|
||||
c.GroupsClaim = val
|
||||
c.EnvOverrides["groupsClaim"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_ALLOWED_GROUPS"]; ok {
|
||||
c.AllowedGroups = parseDelimited(val)
|
||||
c.EnvOverrides["allowedGroups"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_ALLOWED_DOMAINS"]; ok {
|
||||
c.AllowedDomains = parseDelimited(val)
|
||||
c.EnvOverrides["allowedDomains"] = true
|
||||
}
|
||||
if val, ok := env["OIDC_ALLOWED_EMAILS"]; ok {
|
||||
c.AllowedEmails = parseDelimited(val)
|
||||
c.EnvOverrides["allowedEmails"] = true
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type ConfigPersistence struct {
|
||||
webhookFile string
|
||||
nodesFile string
|
||||
systemFile string
|
||||
oidcFile string
|
||||
crypto *crypto.CryptoManager
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ func NewConfigPersistence(configDir string) *ConfigPersistence {
|
||||
webhookFile: filepath.Join(configDir, "webhooks.enc"),
|
||||
nodesFile: filepath.Join(configDir, "nodes.enc"),
|
||||
systemFile: filepath.Join(configDir, "system.json"),
|
||||
oidcFile: filepath.Join(configDir, "oidc.enc"),
|
||||
crypto: cryptoMgr,
|
||||
}
|
||||
|
||||
@@ -620,6 +622,69 @@ func (c *ConfigPersistence) SaveSystemSettings(settings SystemSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveOIDCConfig stores OIDC settings, encrypting them when a crypto manager is available.
|
||||
func (c *ConfigPersistence) SaveOIDCConfig(settings OIDCConfig) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.EnsureConfigDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Do not persist runtime-only flags.
|
||||
settings.EnvOverrides = nil
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.crypto != nil {
|
||||
encrypted, err := c.crypto.Encrypt(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = encrypted
|
||||
}
|
||||
|
||||
if err := os.WriteFile(c.oidcFile, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("file", c.oidcFile).Msg("OIDC configuration saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadOIDCConfig retrieves the persisted OIDC settings. It returns nil when no configuration exists yet.
|
||||
func (c *ConfigPersistence) LoadOIDCConfig() (*OIDCConfig, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
data, err := os.ReadFile(c.oidcFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.crypto != nil {
|
||||
decrypted, err := c.crypto.Decrypt(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = decrypted
|
||||
}
|
||||
|
||||
var settings OIDCConfig
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Str("file", c.oidcFile).Msg("OIDC configuration loaded")
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// LoadSystemSettings loads system settings from file
|
||||
func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
|
||||
c.mu.RLock()
|
||||
@@ -701,4 +766,3 @@ func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSetting
|
||||
// Atomic rename
|
||||
return os.Rename(tempFile, envFile)
|
||||
}
|
||||
|
||||
|
||||
36
scripts/dev/start-oidc-mock.sh
Executable file
36
scripts/dev/start-oidc-mock.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Simple helper that spins up a local Dex server to act as a mock OIDC provider for dev/testing.
|
||||
# Requires Docker. The container exposes the issuer on http://127.0.0.1:5556/dex
|
||||
# and registers a static client `pulse-dev` with secret `pulse-secret`.
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CONFIG_FILE="$PROJECT_ROOT/dev/oidc/dex-config.yaml"
|
||||
CONTAINER_NAME="pulse-oidc-mock"
|
||||
DEX_IMAGE="ghcr.io/dexidp/dex:v2.38.0"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is required to run the mock OIDC provider" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "missing Dex config at $CONFIG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop an existing container if it is already running
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "Stopping existing ${CONTAINER_NAME} container..."
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null
|
||||
fi
|
||||
|
||||
echo "Starting Dex mock OIDC provider on http://127.0.0.1:5556/dex"
|
||||
docker run \
|
||||
--rm \
|
||||
--name "$CONTAINER_NAME" \
|
||||
-p 5556:5556 \
|
||||
-v "$CONFIG_FILE:/etc/dex/config.yaml:ro" \
|
||||
"$DEX_IMAGE" \
|
||||
dex serve /etc/dex/config.yaml
|
||||
Reference in New Issue
Block a user