feat: add OIDC single sign-on

This commit is contained in:
rcourtman
2025-09-29 10:22:27 +00:00
parent 98913040c5
commit 645c793f82
15 changed files with 2375 additions and 555 deletions

30
dev/oidc/dex-config.yaml Normal file
View 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

View File

@@ -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">

View 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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 {

View 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
}

View 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
}

View File

@@ -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")
}
}

View 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
}

View File

@@ -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
View 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
}
}

View File

@@ -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
View 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