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 { setBasicAuth } from '@/utils/apiClient';
import { STORAGE_KEYS } from '@/constants'; import { STORAGE_KEYS } from '@/constants';
@@ -9,13 +9,46 @@ interface LoginProps {
onLogin: () => void; onLogin: () => void;
} }
interface SecurityStatus {
hasAuthentication: boolean;
oidcEnabled?: boolean;
oidcIssuer?: string;
oidcClientId?: string;
oidcEnvOverrides?: Record<string, boolean>;
}
export const Login: Component<LoginProps> = (props) => { export const Login: Component<LoginProps> = (props) => {
const [username, setUsername] = createSignal(''); const [username, setUsername] = createSignal('');
const [password, setPassword] = createSignal(''); const [password, setPassword] = createSignal('');
const [error, setError] = createSignal(''); const [error, setError] = createSignal('');
const [loading, setLoading] = createSignal(false); 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 [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 () => { onMount(async () => {
// Apply saved theme preference from localStorage // Apply saved theme preference from localStorage
@@ -32,7 +65,25 @@ export const Login: Component<LoginProps> = (props) => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
} }
const params = new URLSearchParams(window.location.search);
const oidcStatus = params.get('oidc');
if (oidcStatus === 'error') {
const reason = params.get('oidc_error');
setOidcError(resolveOidcError(reason));
setError('');
} else if (oidcStatus === 'success') {
setOidcMessage('Signed in successfully. Loading Pulse…');
setError('');
}
if (oidcStatus) {
params.delete('oidc');
params.delete('oidc_error');
const newQuery = params.toString();
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`;
window.history.replaceState({}, document.title, newUrl);
}
console.log('[Login] Starting auth check...'); console.log('[Login] Starting auth check...');
try { try {
const response = await fetch('/api/security/status'); 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) => { const handleSubmit = async (e: Event) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@@ -161,7 +262,7 @@ export const Login: Component<LoginProps> = (props) => {
> >
<Show <Show
when={authStatus()?.hasAuthentication === false} 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={ <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"> <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; error: () => string;
loading: () => boolean; loading: () => boolean;
handleSubmit: (e: Event) => void; handleSubmit: (e: Event) => void;
supportsOIDC: () => boolean;
startOidcLogin: () => void | Promise<void>;
oidcLoading: () => boolean;
oidcError: () => string;
oidcMessage: () => string;
}> = (props) => { }> = (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 ( 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"> <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> </p>
</div> </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}> <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" /> <input type="hidden" name="remember" value="true" />
<div class="space-y-4"> <div class="space-y-4">
<div class="relative"> <div class="relative">
@@ -312,4 +461,4 @@ const LoginForm: Component<{
</div> </div>
</div> </div>
); );
}; };

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 toolchain go1.24.7
require ( require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/oauth2 v0.31.0
golang.org/x/term v0.35.0 golang.org/x/term v0.35.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/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/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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 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.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth" internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -62,7 +62,7 @@ func isConnectionSecure(r *http.Request) bool {
func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) { func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) {
isProxied := detectProxy(r) isProxied := detectProxy(r)
isSecure := isConnectionSecure(r) isSecure := isConnectionSecure(r)
// Debug logging for Cloudflare tunnel issues // Debug logging for Cloudflare tunnel issues
if isProxied { if isProxied {
log.Debug(). log.Debug().
@@ -74,10 +74,10 @@ func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) {
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")). Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Msg("Proxy/tunnel detected - adjusting cookie settings") Msg("Proxy/tunnel detected - adjusting cookie settings")
} }
// Default to Lax for better compatibility // Default to Lax for better compatibility
sameSitePolicy := http.SameSiteLaxMode sameSitePolicy := http.SameSiteLaxMode
if isProxied { if isProxied {
// For proxied connections, we need to be more permissive // For proxied connections, we need to be more permissive
// But only use None if connection is secure (required by browsers) // But only use None if connection is secure (required by browsers)
@@ -88,7 +88,7 @@ func getCookieSettings(r *http.Request) (secure bool, sameSite http.SameSite) {
sameSitePolicy = http.SameSiteLaxMode sameSitePolicy = http.SameSiteLaxMode
} }
} }
return isSecure, sameSitePolicy return isSecure, sameSitePolicy
} }
@@ -114,7 +114,7 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
if cfg.ProxyAuthSecret == "" { if cfg.ProxyAuthSecret == "" {
return false, "", false return false, "", false
} }
// Validate proxy secret header // Validate proxy secret header
proxySecret := r.Header.Get("X-Proxy-Secret") proxySecret := r.Header.Get("X-Proxy-Secret")
if proxySecret != cfg.ProxyAuthSecret { if proxySecret != cfg.ProxyAuthSecret {
@@ -123,7 +123,7 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
Msg("Invalid proxy secret") Msg("Invalid proxy secret")
return false, "", false return false, "", false
} }
// Get username from header if configured // Get username from header if configured
username := "" username := ""
if cfg.ProxyAuthUserHeader != "" { if cfg.ProxyAuthUserHeader != "" {
@@ -133,7 +133,7 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
return false, "", false return false, "", false
} }
} }
// Check admin role if configured // Check admin role if configured
isAdmin := true // Default to admin if no role checking configured isAdmin := true // Default to admin if no role checking configured
if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" { if cfg.ProxyAuthRoleHeader != "" && cfg.ProxyAuthAdminRole != "" {
@@ -158,12 +158,12 @@ func CheckProxyAuth(cfg *config.Config, r *http.Request) (bool, string, bool) {
Msg("Proxy auth roles checked") Msg("Proxy auth roles checked")
} }
} }
log.Debug(). log.Debug().
Str("user", username). Str("user", username).
Bool("is_admin", isAdmin). Bool("is_admin", isAdmin).
Msg("Proxy authentication successful") Msg("Proxy authentication successful")
return true, username, isAdmin return true, username, isAdmin
} }
@@ -182,7 +182,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
w.Header().Set("X-Auth-Disabled", "true") w.Header().Set("X-Auth-Disabled", "true")
return true return true
} }
// Check proxy auth first if configured // Check proxy auth first if configured
if cfg.ProxyAuthSecret != "" { if cfg.ProxyAuthSecret != "" {
if valid, username, _ := CheckProxyAuth(cfg, r); valid { if valid, username, _ := CheckProxyAuth(cfg, r); valid {
@@ -194,13 +194,17 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
return true return true
} }
} }
// 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.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken == "" && cfg.ProxyAuthSecret == "" {
log.Debug().Msg("No auth configured, allowing access") if cfg.OIDC != nil && cfg.OIDC.Enabled {
return true 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) // API-only mode: when only API token is configured (no password auth)
// Allow read-only endpoints for the UI to work // Allow read-only endpoints for the UI to work
if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken != "" { if cfg.AuthUser == "" && cfg.AuthPass == "" && cfg.APIToken != "" {
@@ -209,7 +213,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
if providedToken == "" { if providedToken == "" {
providedToken = r.URL.Query().Get("token") providedToken = r.URL.Query().Get("token")
} }
// If a token was provided, validate it // If a token was provided, validate it
if providedToken != "" { if providedToken != "" {
// Use secure token comparison // Use secure token comparison
@@ -222,7 +226,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
} }
return false return false
} }
// No token provided - allow read-only endpoints for UI // No token provided - allow read-only endpoints for UI
if r.Method == "GET" || r.URL.Path == "/ws" { if r.Method == "GET" || r.URL.Path == "/ws" {
// Allow these endpoints without auth for UI to function // Allow these endpoints without auth for UI to function
@@ -247,7 +251,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
} }
} }
} }
// Require token for everything else // Require token for everything else
if w != nil { if w != nil {
w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`) w.Header().Set("WWW-Authenticate", `Bearer realm="API Token Required"`)
@@ -255,14 +259,14 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
} }
return false return false
} }
log.Debug(). log.Debug().
Str("configured_user", cfg.AuthUser). Str("configured_user", cfg.AuthUser).
Bool("has_pass", cfg.AuthPass != ""). Bool("has_pass", cfg.AuthPass != "").
Bool("has_token", cfg.APIToken != ""). Bool("has_token", cfg.APIToken != "").
Str("url", r.URL.Path). Str("url", r.URL.Path).
Msg("Checking authentication") Msg("Checking authentication")
// Check API token first (for backward compatibility) // Check API token first (for backward compatibility)
if cfg.APIToken != "" { if cfg.APIToken != "" {
// Check header // Check header
@@ -280,7 +284,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
} }
} }
} }
// Check session cookie (for WebSocket and UI) // Check session cookie (for WebSocket and UI)
if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" { if cookie, err := r.Cookie("pulse_session"); err == nil && cookie.Value != "" {
if ValidateSession(cookie.Value) { if ValidateSession(cookie.Value) {
@@ -300,7 +304,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
Bool("has_cf_headers", r.Header.Get("CF-Ray") != ""). Bool("has_cf_headers", r.Header.Get("CF-Ray") != "").
Msg("No session cookie found") Msg("No session cookie found")
} }
// Check basic auth // Check basic auth
if cfg.AuthUser != "" && cfg.AuthPass != "" { if cfg.AuthUser != "" && cfg.AuthPass != "" {
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
@@ -313,7 +317,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
parts := strings.SplitN(string(decoded), ":", 2) parts := strings.SplitN(string(decoded), ":", 2)
if len(parts) == 2 { if len(parts) == 2 {
clientIP := GetClientIP(r) clientIP := GetClientIP(r)
// Only apply rate limiting for actual login attempts, not regular auth checks // Only apply rate limiting for actual login attempts, not regular auth checks
// Login attempts come to /api/login endpoint // Login attempts come to /api/login endpoint
if r.URL.Path == "/api/login" { if r.URL.Path == "/api/login" {
@@ -327,72 +331,72 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
return false return false
} }
} }
// Check if account is locked out // Check if account is locked out
_, userLockedUntil, userLocked := GetLockoutInfo(parts[0]) _, userLockedUntil, userLocked := GetLockoutInfo(parts[0])
_, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP) _, ipLockedUntil, ipLocked := GetLockoutInfo(clientIP)
if userLocked || ipLocked { if userLocked || ipLocked {
lockedUntil := userLockedUntil lockedUntil := userLockedUntil
if ipLocked && ipLockedUntil.After(lockedUntil) { if ipLocked && ipLockedUntil.After(lockedUntil) {
lockedUntil = ipLockedUntil lockedUntil = ipLockedUntil
} }
remainingMinutes := int(time.Until(lockedUntil).Minutes()) remainingMinutes := int(time.Until(lockedUntil).Minutes())
if remainingMinutes < 1 { if remainingMinutes < 1 {
remainingMinutes = 1 remainingMinutes = 1
} }
log.Warn().Str("user", parts[0]).Str("ip", clientIP).Msg("Account locked out") log.Warn().Str("user", parts[0]).Str("ip", clientIP).Msg("Account locked out")
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Account locked") LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Account locked")
if w != nil { if w != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
w.Write([]byte(fmt.Sprintf(`{"error":"Account temporarily locked","message":"Too many failed attempts. Please try again in %d minutes.","lockedUntil":"%s"}`, w.Write([]byte(fmt.Sprintf(`{"error":"Account temporarily locked","message":"Too many failed attempts. Please try again in %d minutes.","lockedUntil":"%s"}`,
remainingMinutes, lockedUntil.Format(time.RFC3339)))) remainingMinutes, lockedUntil.Format(time.RFC3339))))
} }
return false return false
} }
// Check username // Check username
userMatch := parts[0] == cfg.AuthUser userMatch := parts[0] == cfg.AuthUser
// Check password - support both hashed and plain text for migration // Check password - support both hashed and plain text for migration
// Config always has hashed password now (auto-hashed on load) // Config always has hashed password now (auto-hashed on load)
passMatch := internalauth.CheckPasswordHash(parts[1], cfg.AuthPass) passMatch := internalauth.CheckPasswordHash(parts[1], cfg.AuthPass)
log.Debug(). log.Debug().
Str("provided_user", parts[0]). Str("provided_user", parts[0]).
Str("expected_user", cfg.AuthUser). Str("expected_user", cfg.AuthUser).
Bool("user_match", userMatch). Bool("user_match", userMatch).
Bool("pass_match", passMatch). Bool("pass_match", passMatch).
Msg("Auth check") Msg("Auth check")
if userMatch && passMatch { if userMatch && passMatch {
// Clear failed login attempts // Clear failed login attempts
ClearFailedLogins(parts[0]) ClearFailedLogins(parts[0])
ClearFailedLogins(GetClientIP(r)) ClearFailedLogins(GetClientIP(r))
// Valid credentials - create session // Valid credentials - create session
if w != nil { if w != nil {
token := generateSessionToken() token := generateSessionToken()
if token == "" { if token == "" {
return false return false
} }
// Store session persistently // Store session persistently
userAgent := r.Header.Get("User-Agent") userAgent := r.Header.Get("User-Agent")
clientIP := GetClientIP(r) clientIP := GetClientIP(r)
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP) GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP)
// Track session for user // Track session for user
TrackUserSession(parts[0], token) TrackUserSession(parts[0], token)
// Generate CSRF token // Generate CSRF token
csrfToken := generateCSRFToken(token) csrfToken := generateCSRFToken(token)
// Get appropriate cookie settings based on proxy detection // Get appropriate cookie settings based on proxy detection
isSecure, sameSitePolicy := getCookieSettings(r) isSecure, sameSitePolicy := getCookieSettings(r)
// Debug logging for Cloudflare tunnel issues // Debug logging for Cloudflare tunnel issues
sameSiteName := "Default" sameSiteName := "Default"
switch sameSitePolicy { switch sameSitePolicy {
@@ -403,14 +407,14 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
case http.SameSiteStrictMode: case http.SameSiteStrictMode:
sameSiteName = "Strict" sameSiteName = "Strict"
} }
log.Debug(). log.Debug().
Bool("secure", isSecure). Bool("secure", isSecure).
Str("same_site", sameSiteName). Str("same_site", sameSiteName).
Str("token", token[:8]+"..."). Str("token", token[:8]+"...").
Str("remote_addr", r.RemoteAddr). Str("remote_addr", r.RemoteAddr).
Msg("Setting session cookie after successful login") Msg("Setting session cookie after successful login")
// Set session cookie // Set session cookie
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pulse_session", Name: "pulse_session",
@@ -421,7 +425,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
SameSite: sameSitePolicy, SameSite: sameSitePolicy,
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours
}) })
// Set CSRF cookie (not HttpOnly so JS can read it) // Set CSRF cookie (not HttpOnly so JS can read it)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pulse_csrf", Name: "pulse_csrf",
@@ -431,7 +435,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
SameSite: sameSitePolicy, SameSite: sameSitePolicy,
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours
}) })
// Audit log successful login // Audit log successful login
LogAuditEvent("login", parts[0], GetClientIP(r), r.URL.Path, true, "Basic auth login") LogAuditEvent("login", parts[0], GetClientIP(r), r.URL.Path, true, "Basic auth login")
} }
@@ -441,27 +445,27 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
RecordFailedLogin(parts[0]) RecordFailedLogin(parts[0])
RecordFailedLogin(clientIP) RecordFailedLogin(clientIP)
LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Invalid credentials") LogAuditEvent("login", parts[0], clientIP, r.URL.Path, false, "Invalid credentials")
// Get updated attempt counts // Get updated attempt counts
newUserAttempts, _, _ := GetLockoutInfo(parts[0]) newUserAttempts, _, _ := GetLockoutInfo(parts[0])
newIPAttempts, _, _ := GetLockoutInfo(clientIP) newIPAttempts, _, _ := GetLockoutInfo(clientIP)
// Use the higher count for warning // Use the higher count for warning
attempts := newUserAttempts attempts := newUserAttempts
if newIPAttempts > attempts { if newIPAttempts > attempts {
attempts = newIPAttempts attempts = newIPAttempts
} }
if r.URL.Path == "/api/login" && w != nil { if r.URL.Path == "/api/login" && w != nil {
// For login endpoint, provide detailed error response // For login endpoint, provide detailed error response
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
remaining := maxFailedAttempts - attempts remaining := maxFailedAttempts - attempts
if remaining > 0 { if remaining > 0 {
w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`, w.Write([]byte(fmt.Sprintf(`{"error":"Invalid credentials","attempts":%d,"remaining":%d,"maxAttempts":%d}`,
attempts, remaining, maxFailedAttempts))) attempts, remaining, maxFailedAttempts)))
} else { } 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 return false
} }
@@ -471,7 +475,7 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
} }
} }
} }
return false return false
} }
@@ -482,14 +486,14 @@ func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
handler(w, r) handler(w, r)
return return
} }
// Log the failed attempt // Log the failed attempt
log.Warn(). log.Warn().
Str("ip", r.RemoteAddr). Str("ip", r.RemoteAddr).
Str("path", r.URL.Path). Str("path", r.URL.Path).
Str("method", r.Method). Str("method", r.Method).
Msg("Unauthorized access attempt") Msg("Unauthorized access attempt")
// Never send WWW-Authenticate header - we want to use our custom login page // Never send WWW-Authenticate header - we want to use our custom login page
// The frontend will detect 401 responses and show the login component // The frontend will detect 401 responses and show the login component
// Return JSON error for API requests, plain text for others // Return JSON error for API requests, plain text for others
@@ -516,7 +520,7 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
Str("path", r.URL.Path). Str("path", r.URL.Path).
Str("method", r.Method). Str("method", r.Method).
Msg("Unauthorized access attempt") Msg("Unauthorized access attempt")
// Return authentication error // Return authentication error
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") { if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -527,7 +531,7 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
} }
return return
} }
// Check if using proxy auth and if so, verify admin status // Check if using proxy auth and if so, verify admin status
if cfg.ProxyAuthSecret != "" { if cfg.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid { if valid, username, isAdmin := CheckProxyAuth(cfg, r); valid {
@@ -539,7 +543,7 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
Str("method", r.Method). Str("method", r.Method).
Str("username", username). Str("username", username).
Msg("Non-admin user attempted to access admin endpoint") Msg("Non-admin user attempted to access admin endpoint")
// Return forbidden error // Return forbidden error
if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") { if strings.HasPrefix(r.URL.Path, "/api/") || strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -552,8 +556,8 @@ func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc
} }
} }
} }
// User is authenticated and has admin privileges (or not using proxy auth) // User is authenticated and has admin privileges (or not using proxy auth)
handler(w, r) handler(w, r)
} }
} }

View File

@@ -57,6 +57,11 @@ func getFrontendDevProxy() (*httputil.ReverseProxy, error) {
// getFrontendFS returns the embedded frontend filesystem // getFrontendFS returns the embedded frontend filesystem
func getFrontendFS() (http.FileSystem, error) { 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 // Strip the prefix to serve files from root
fsys, err := fs.Sub(embeddedFrontend, "frontend-modern/dist") fsys, err := fs.Sub(embeddedFrontend, "frontend-modern/dist")
if err != nil { 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
}

File diff suppressed because it is too large Load Diff

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:"|"` ProxyAuthRoleSeparator string `envconfig:"PROXY_AUTH_ROLE_SEPARATOR" default:"|"`
ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"` ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"`
ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"` ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"`
// OIDC configuration
OIDC *OIDCConfig `json:"-"`
// HTTPS/TLS settings // HTTPS/TLS settings
HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"` HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"`
TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""` TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""`
@@ -221,6 +224,7 @@ func Load() (*Config, error) {
DiscoveryEnabled: true, DiscoveryEnabled: true,
DiscoverySubnet: "auto", DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool), EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
} }
// Initialize persistence // Initialize persistence
@@ -287,6 +291,12 @@ func Load() (*Config, error) {
log.Warn().Err(err).Msg("Failed to create default system.json") 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 // 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") 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 != "" { if authUser := os.Getenv("PULSE_AUTH_USER"); authUser != "" {
cfg.AuthUser = authUser cfg.AuthUser = authUser
log.Info().Msg("Overriding auth user from env var") 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 // Set log level
switch cfg.LogLevel { switch cfg.LogLevel {
case "debug": case "debug":
@@ -529,6 +582,23 @@ func SaveConfig(cfg *Config) error {
return nil 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 // Validate checks if the configuration is valid
func (c *Config) Validate() error { func (c *Config) Validate() error {
// Validate server settings // Validate server settings
@@ -580,6 +650,10 @@ func (c *Config) Validate() error {
} }
c.PBSInstances = validPBS c.PBSInstances = validPBS
if err := c.OIDC.Validate(); err != nil {
return err
}
return nil 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 webhookFile string
nodesFile string nodesFile string
systemFile string systemFile string
oidcFile string
crypto *crypto.CryptoManager crypto *crypto.CryptoManager
} }
@@ -32,14 +33,14 @@ func NewConfigPersistence(configDir string) *ConfigPersistence {
if configDir == "" { if configDir == "" {
configDir = "/etc/pulse" configDir = "/etc/pulse"
} }
// Initialize crypto manager // Initialize crypto manager
cryptoMgr, err := crypto.NewCryptoManager() cryptoMgr, err := crypto.NewCryptoManager()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to initialize crypto manager, using unencrypted storage") log.Error().Err(err).Msg("Failed to initialize crypto manager, using unencrypted storage")
cryptoMgr = nil cryptoMgr = nil
} }
cp := &ConfigPersistence{ cp := &ConfigPersistence{
configDir: configDir, configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"), alertFile: filepath.Join(configDir, "alerts.json"),
@@ -47,16 +48,17 @@ func NewConfigPersistence(configDir string) *ConfigPersistence {
webhookFile: filepath.Join(configDir, "webhooks.enc"), webhookFile: filepath.Join(configDir, "webhooks.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"), nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"), systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
crypto: cryptoMgr, crypto: cryptoMgr,
} }
log.Debug(). log.Debug().
Str("configDir", configDir). Str("configDir", configDir).
Str("systemFile", cp.systemFile). Str("systemFile", cp.systemFile).
Str("nodesFile", cp.nodesFile). Str("nodesFile", cp.nodesFile).
Bool("encryptionEnabled", cryptoMgr != nil). Bool("encryptionEnabled", cryptoMgr != nil).
Msg("Config persistence initialized") Msg("Config persistence initialized")
return cp return cp
} }
@@ -69,7 +71,7 @@ func (c *ConfigPersistence) EnsureConfigDir() error {
func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error { func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
// Ensure critical defaults are set before saving // Ensure critical defaults are set before saving
if config.StorageDefault.Trigger <= 0 { if config.StorageDefault.Trigger <= 0 {
config.StorageDefault.Trigger = 85 config.StorageDefault.Trigger = 85
@@ -84,20 +86,20 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
if config.HysteresisMargin <= 0 { if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0 config.HysteresisMargin = 5.0
} }
data, err := json.MarshalIndent(config, "", " ") data, err := json.MarshalIndent(config, "", " ")
if err != nil { if err != nil {
return err return err
} }
if err := c.EnsureConfigDir(); err != nil { if err := c.EnsureConfigDir(); err != nil {
return err return err
} }
if err := os.WriteFile(c.alertFile, data, 0600); err != nil { if err := os.WriteFile(c.alertFile, data, 0600); err != nil {
return err return err
} }
log.Info().Str("file", c.alertFile).Msg("Alert configuration saved") log.Info().Str("file", c.alertFile).Msg("Alert configuration saved")
return nil return nil
} }
@@ -106,7 +108,7 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
data, err := os.ReadFile(c.alertFile) data, err := os.ReadFile(c.alertFile)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -132,12 +134,12 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
} }
return nil, err return nil, err
} }
var config alerts.AlertConfig var config alerts.AlertConfig
if err := json.Unmarshal(data, &config); err != nil { if err := json.Unmarshal(data, &config); err != nil {
return nil, err return nil, err
} }
// For empty config files ({}), enable alerts by default // For empty config files ({}), enable alerts by default
// This handles the case where the file exists but is empty // This handles the case where the file exists but is empty
if string(data) == "{}" { if string(data) == "{}" {
@@ -156,7 +158,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
if config.HysteresisMargin <= 0 { if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0 config.HysteresisMargin = 5.0
} }
// Migration: Set I/O metrics to Off (0) if they have the old default values // Migration: Set I/O metrics to Off (0) if they have the old default values
// This helps existing users avoid noisy I/O alerts // This helps existing users avoid noisy I/O alerts
if config.GuestDefaults.DiskRead != nil && config.GuestDefaults.DiskRead.Trigger == 150 { if config.GuestDefaults.DiskRead != nil && config.GuestDefaults.DiskRead.Trigger == 150 {
@@ -171,7 +173,7 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
if config.GuestDefaults.NetworkOut != nil && config.GuestDefaults.NetworkOut.Trigger == 200 { if config.GuestDefaults.NetworkOut != nil && config.GuestDefaults.NetworkOut.Trigger == 200 {
config.GuestDefaults.NetworkOut = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0} config.GuestDefaults.NetworkOut = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
} }
log.Info(). log.Info().
Str("file", c.alertFile). Str("file", c.alertFile).
Bool("enabled", config.Enabled). Bool("enabled", config.Enabled).
@@ -183,17 +185,17 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
func (c *ConfigPersistence) SaveEmailConfig(config notifications.EmailConfig) error { func (c *ConfigPersistence) SaveEmailConfig(config notifications.EmailConfig) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
// Marshal to JSON first // Marshal to JSON first
data, err := json.MarshalIndent(config, "", " ") data, err := json.MarshalIndent(config, "", " ")
if err != nil { if err != nil {
return err return err
} }
if err := c.EnsureConfigDir(); err != nil { if err := c.EnsureConfigDir(); err != nil {
return err return err
} }
// Encrypt if crypto manager is available // Encrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data) encrypted, err := c.crypto.Encrypt(data)
@@ -202,12 +204,12 @@ func (c *ConfigPersistence) SaveEmailConfig(config notifications.EmailConfig) er
} }
data = encrypted data = encrypted
} }
// Save with restricted permissions (owner read/write only) // Save with restricted permissions (owner read/write only)
if err := os.WriteFile(c.emailFile, data, 0600); err != nil { if err := os.WriteFile(c.emailFile, data, 0600); err != nil {
return err return err
} }
log.Info(). log.Info().
Str("file", c.emailFile). Str("file", c.emailFile).
Bool("encrypted", c.crypto != nil). Bool("encrypted", c.crypto != nil).
@@ -219,7 +221,7 @@ func (c *ConfigPersistence) SaveEmailConfig(config notifications.EmailConfig) er
func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error) { func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
data, err := os.ReadFile(c.emailFile) data, err := os.ReadFile(c.emailFile)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -233,7 +235,7 @@ func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error
} }
return nil, err return nil, err
} }
// Decrypt if crypto manager is available // Decrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data) decrypted, err := c.crypto.Decrypt(data)
@@ -242,12 +244,12 @@ func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error
} }
data = decrypted data = decrypted
} }
var config notifications.EmailConfig var config notifications.EmailConfig
if err := json.Unmarshal(data, &config); err != nil { if err := json.Unmarshal(data, &config); err != nil {
return nil, err return nil, err
} }
log.Info(). log.Info().
Str("file", c.emailFile). Str("file", c.emailFile).
Bool("encrypted", c.crypto != nil). Bool("encrypted", c.crypto != nil).
@@ -259,16 +261,16 @@ func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error
func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig) error { func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
data, err := json.MarshalIndent(webhooks, "", " ") data, err := json.MarshalIndent(webhooks, "", " ")
if err != nil { if err != nil {
return err return err
} }
if err := c.EnsureConfigDir(); err != nil { if err := c.EnsureConfigDir(); err != nil {
return err return err
} }
// Encrypt if crypto manager is available // Encrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data) encrypted, err := c.crypto.Encrypt(data)
@@ -277,11 +279,11 @@ func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig)
} }
data = encrypted data = encrypted
} }
if err := os.WriteFile(c.webhookFile, data, 0600); err != nil { if err := os.WriteFile(c.webhookFile, data, 0600); err != nil {
return err return err
} }
log.Info().Str("file", c.webhookFile). log.Info().Str("file", c.webhookFile).
Int("count", len(webhooks)). Int("count", len(webhooks)).
Bool("encrypted", c.crypto != nil). Bool("encrypted", c.crypto != nil).
@@ -293,7 +295,7 @@ func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig)
func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error) { func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
// First try to load from encrypted file // First try to load from encrypted file
data, err := os.ReadFile(c.webhookFile) data, err := os.ReadFile(c.webhookFile)
if err != nil { if err != nil {
@@ -309,7 +311,7 @@ func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error
Str("file", legacyFile). Str("file", legacyFile).
Int("count", len(webhooks)). Int("count", len(webhooks)).
Msg("Found unencrypted webhooks - migration needed") Msg("Found unencrypted webhooks - migration needed")
// Return the loaded webhooks - migration will be handled by caller // Return the loaded webhooks - migration will be handled by caller
return webhooks, nil return webhooks, nil
} }
@@ -319,7 +321,7 @@ func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error
} }
return nil, err return nil, err
} }
// Decrypt if crypto manager is available // Decrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data) decrypted, err := c.crypto.Decrypt(data)
@@ -337,12 +339,12 @@ func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error
} }
data = decrypted data = decrypted
} }
var webhooks []notifications.WebhookConfig var webhooks []notifications.WebhookConfig
if err := json.Unmarshal(data, &webhooks); err != nil { if err := json.Unmarshal(data, &webhooks); err != nil {
return nil, err return nil, err
} }
log.Info(). log.Info().
Str("file", c.webhookFile). Str("file", c.webhookFile).
Int("count", len(webhooks)). Int("count", len(webhooks)).
@@ -358,7 +360,7 @@ func (c *ConfigPersistence) MigrateWebhooksIfNeeded() error {
// Encrypted file exists, no migration needed // Encrypted file exists, no migration needed
return nil return nil
} }
// Check for legacy unencrypted file // Check for legacy unencrypted file
legacyFile := filepath.Join(c.configDir, "webhooks.json") legacyFile := filepath.Join(c.configDir, "webhooks.json")
legacyData, err := os.ReadFile(legacyFile) legacyData, err := os.ReadFile(legacyFile)
@@ -369,24 +371,24 @@ func (c *ConfigPersistence) MigrateWebhooksIfNeeded() error {
} }
return fmt.Errorf("failed to read legacy webhooks file: %w", err) return fmt.Errorf("failed to read legacy webhooks file: %w", err)
} }
// Parse legacy webhooks // Parse legacy webhooks
var webhooks []notifications.WebhookConfig var webhooks []notifications.WebhookConfig
if err := json.Unmarshal(legacyData, &webhooks); err != nil { if err := json.Unmarshal(legacyData, &webhooks); err != nil {
return fmt.Errorf("failed to parse legacy webhooks: %w", err) return fmt.Errorf("failed to parse legacy webhooks: %w", err)
} }
log.Info(). log.Info().
Str("from", legacyFile). Str("from", legacyFile).
Str("to", c.webhookFile). Str("to", c.webhookFile).
Int("count", len(webhooks)). Int("count", len(webhooks)).
Msg("Migrating webhooks to encrypted format") Msg("Migrating webhooks to encrypted format")
// Save to encrypted file // Save to encrypted file
if err := c.SaveWebhooks(webhooks); err != nil { if err := c.SaveWebhooks(webhooks); err != nil {
return fmt.Errorf("failed to save encrypted webhooks: %w", err) return fmt.Errorf("failed to save encrypted webhooks: %w", err)
} }
// Create backup of original file // Create backup of original file
backupFile := legacyFile + ".backup" backupFile := legacyFile + ".backup"
if err := os.Rename(legacyFile, backupFile); err != nil { if err := os.Rename(legacyFile, backupFile); err != nil {
@@ -394,7 +396,7 @@ func (c *ConfigPersistence) MigrateWebhooksIfNeeded() error {
} else { } else {
log.Info().Str("backup", backupFile).Msg("Legacy webhooks file backed up") log.Info().Str("backup", backupFile).Msg("Legacy webhooks file backed up")
} }
return nil return nil
} }
@@ -407,7 +409,7 @@ type NodesConfig struct {
// SystemSettings represents system configuration settings // SystemSettings represents system configuration settings
type SystemSettings struct { type SystemSettings struct {
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s // Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
BackendPort int `json:"backendPort,omitempty"` BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"` FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"` AllowedOrigins string `json:"allowedOrigins,omitempty"`
@@ -419,8 +421,8 @@ type SystemSettings struct {
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
DiscoveryEnabled bool `json:"discoveryEnabled"` DiscoveryEnabled bool `json:"discoveryEnabled"`
DiscoverySubnet string `json:"discoverySubnet,omitempty"` DiscoverySubnet string `json:"discoverySubnet,omitempty"`
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
// APIToken removed - now handled via .env file only // APIToken removed - now handled via .env file only
} }
@@ -433,24 +435,24 @@ func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInsta
log.Warn().Msg("Skipping nodes save - mock mode is enabled") log.Warn().Msg("Skipping nodes save - mock mode is enabled")
return nil // Silently succeed to prevent errors but don't save return nil // Silently succeed to prevent errors but don't save
} }
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
config := NodesConfig{ config := NodesConfig{
PVEInstances: pveInstances, PVEInstances: pveInstances,
PBSInstances: pbsInstances, PBSInstances: pbsInstances,
} }
data, err := json.MarshalIndent(config, "", " ") data, err := json.MarshalIndent(config, "", " ")
if err != nil { if err != nil {
return err return err
} }
if err := c.EnsureConfigDir(); err != nil { if err := c.EnsureConfigDir(); err != nil {
return err return err
} }
// Encrypt if crypto manager is available // Encrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data) encrypted, err := c.crypto.Encrypt(data)
@@ -459,11 +461,11 @@ func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInsta
} }
data = encrypted data = encrypted
} }
if err := os.WriteFile(c.nodesFile, data, 0600); err != nil { if err := os.WriteFile(c.nodesFile, data, 0600); err != nil {
return err return err
} }
log.Info().Str("file", c.nodesFile). log.Info().Str("file", c.nodesFile).
Int("pve", len(pveInstances)). Int("pve", len(pveInstances)).
Int("pbs", len(pbsInstances)). Int("pbs", len(pbsInstances)).
@@ -476,7 +478,7 @@ func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInsta
func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) { func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
data, err := os.ReadFile(c.nodesFile) data, err := os.ReadFile(c.nodesFile)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -489,7 +491,7 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
} }
return nil, err return nil, err
} }
// Decrypt if crypto manager is available // Decrypt if crypto manager is available
if c.crypto != nil { if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data) decrypted, err := c.crypto.Decrypt(data)
@@ -498,15 +500,15 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
} }
data = decrypted data = decrypted
} }
var config NodesConfig var config NodesConfig
if err := json.Unmarshal(data, &config); err != nil { if err := json.Unmarshal(data, &config); err != nil {
return nil, err return nil, err
} }
// Track if any migrations were applied // Track if any migrations were applied
migrationApplied := false migrationApplied := false
// Fix for bug where TokenName was incorrectly set when using password auth // Fix for bug where TokenName was incorrectly set when using password auth
// If a PBS instance has both Password and TokenName, clear the TokenName // If a PBS instance has both Password and TokenName, clear the TokenName
for i := range config.PBSInstances { for i := range config.PBSInstances {
@@ -517,7 +519,7 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
config.PBSInstances[i].TokenName = "" config.PBSInstances[i].TokenName = ""
config.PBSInstances[i].TokenValue = "" config.PBSInstances[i].TokenValue = ""
} }
// Fix for missing port in PBS host // Fix for missing port in PBS host
host := config.PBSInstances[i].Host host := config.PBSInstances[i].Host
if host != "" { if host != "" {
@@ -560,7 +562,7 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
} }
} }
} }
// Migration: Ensure MonitorBackups is enabled for PBS instances // Migration: Ensure MonitorBackups is enabled for PBS instances
// This fixes issue #411 where PBS backups weren't showing // This fixes issue #411 where PBS backups weren't showing
if !config.PBSInstances[i].MonitorBackups { if !config.PBSInstances[i].MonitorBackups {
@@ -571,7 +573,7 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
migrationApplied = true migrationApplied = true
} }
} }
// If any migrations were applied, save the updated configuration // If any migrations were applied, save the updated configuration
if migrationApplied { if migrationApplied {
log.Info().Msg("Migrations applied, saving updated configuration") log.Info().Msg("Migrations applied, saving updated configuration")
@@ -582,7 +584,7 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
} }
c.mu.RLock() c.mu.RLock()
} }
log.Info().Str("file", c.nodesFile). log.Info().Str("file", c.nodesFile).
Int("pve", len(config.PVEInstances)). Int("pve", len(config.PVEInstances)).
Int("pbs", len(config.PBSInstances)). Int("pbs", len(config.PBSInstances)).
@@ -595,36 +597,99 @@ func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
func (c *ConfigPersistence) SaveSystemSettings(settings SystemSettings) error { func (c *ConfigPersistence) SaveSystemSettings(settings SystemSettings) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
data, err := json.MarshalIndent(settings, "", " ") data, err := json.MarshalIndent(settings, "", " ")
if err != nil { if err != nil {
return err return err
} }
if err := c.EnsureConfigDir(); err != nil { if err := c.EnsureConfigDir(); err != nil {
return err return err
} }
if err := os.WriteFile(c.systemFile, data, 0600); err != nil { if err := os.WriteFile(c.systemFile, data, 0600); err != nil {
return err return err
} }
// Also update the .env file if it exists // Also update the .env file if it exists
envFile := filepath.Join(c.configDir, ".env") envFile := filepath.Join(c.configDir, ".env")
if err := c.updateEnvFile(envFile, settings); err != nil { if err := c.updateEnvFile(envFile, settings); err != nil {
log.Warn().Err(err).Msg("Failed to update .env file") log.Warn().Err(err).Msg("Failed to update .env file")
// Don't fail the operation if .env update fails // Don't fail the operation if .env update fails
} }
log.Info().Str("file", c.systemFile).Msg("System settings saved") log.Info().Str("file", c.systemFile).Msg("System settings saved")
return nil 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 // LoadSystemSettings loads system settings from file
func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) { func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
data, err := os.ReadFile(c.systemFile) data, err := os.ReadFile(c.systemFile)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -633,12 +698,12 @@ func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
} }
return nil, err return nil, err
} }
var settings SystemSettings var settings SystemSettings
if err := json.Unmarshal(data, &settings); err != nil { if err := json.Unmarshal(data, &settings); err != nil {
return nil, err return nil, err
} }
log.Info().Str("file", c.systemFile).Msg("System settings loaded") log.Info().Str("file", c.systemFile).Msg("System settings loaded")
return &settings, nil return &settings, nil
} }
@@ -650,20 +715,20 @@ func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSetting
// File doesn't exist, nothing to update // File doesn't exist, nothing to update
return nil return nil
} }
// Read the existing .env file // Read the existing .env file
file, err := os.Open(envFile) file, err := os.Open(envFile)
if err != nil { if err != nil {
return err return err
} }
defer file.Close() defer file.Close()
var lines []string var lines []string
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
// Skip POLLING_INTERVAL lines - deprecated // Skip POLLING_INTERVAL lines - deprecated
if strings.HasPrefix(line, "POLLING_INTERVAL=") { if strings.HasPrefix(line, "POLLING_INTERVAL=") {
// Skip this line, polling interval is now hardcoded // Skip this line, polling interval is now hardcoded
@@ -679,26 +744,25 @@ func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSetting
lines = append(lines, line) lines = append(lines, line)
} }
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return err return err
} }
// Note: POLLING_INTERVAL is deprecated and no longer written // Note: POLLING_INTERVAL is deprecated and no longer written
// Write the updated content back atomically // Write the updated content back atomically
content := strings.Join(lines, "\n") content := strings.Join(lines, "\n")
if len(lines) > 0 && !strings.HasSuffix(content, "\n") { if len(lines) > 0 && !strings.HasSuffix(content, "\n") {
content += "\n" content += "\n"
} }
// Write to temp file first // Write to temp file first
tempFile := envFile + ".tmp" tempFile := envFile + ".tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil { if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return err return err
} }
// Atomic rename // Atomic rename
return os.Rename(tempFile, envFile) 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