mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
feat: add OIDC single sign-on
This commit is contained in:
30
dev/oidc/dex-config.yaml
Normal file
30
dev/oidc/dex-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
issuer: http://127.0.0.1:5556/dex
|
||||||
|
storage:
|
||||||
|
type: memory
|
||||||
|
web:
|
||||||
|
http: 0.0.0.0:5556
|
||||||
|
frontend:
|
||||||
|
issuer: Pulse Mock IDP
|
||||||
|
dir: /srv/dex/web
|
||||||
|
logger:
|
||||||
|
level: info
|
||||||
|
format: text
|
||||||
|
oauth2:
|
||||||
|
skipApprovalScreen: true
|
||||||
|
responseTypes: ["code", "token", "id_token"]
|
||||||
|
alwaysShowLoginScreen: true
|
||||||
|
staticClients:
|
||||||
|
- id: pulse-dev
|
||||||
|
name: Pulse Dev
|
||||||
|
secret: pulse-secret
|
||||||
|
redirectURIs:
|
||||||
|
- http://127.0.0.1:5173/api/oidc/callback
|
||||||
|
- http://127.0.0.1:7655/api/oidc/callback
|
||||||
|
- http://127.0.0.1:8765/api/oidc/callback
|
||||||
|
staticPasswords:
|
||||||
|
- email: admin@example.com
|
||||||
|
hash: "$2a$10$uo8fC/3BtvIULFvS7/NuRe6Bn3NmidSXHHiAchpdZEiBBV3IcJKfy"
|
||||||
|
username: admin
|
||||||
|
userID: 19d82f09-9a6b-4f38-a6d8-2c4ed1faff42
|
||||||
|
displayName: Admin User
|
||||||
|
enablePasswordDB: true
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, createSignal, Show, onMount, lazy, Suspense } from 'solid-js';
|
import { Component, createSignal, Show, onMount, lazy, Suspense, createEffect } from 'solid-js';
|
||||||
import { setBasicAuth } from '@/utils/apiClient';
|
import { 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
417
frontend-modern/src/components/Settings/OIDCPanel.tsx
Normal file
417
frontend-modern/src/components/Settings/OIDCPanel.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import { Component, Show, createSignal, onMount } from 'solid-js';
|
||||||
|
import { createStore } from 'solid-js/store';
|
||||||
|
import { Card } from '@/components/shared/Card';
|
||||||
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||||
|
import { Toggle } from '@/components/shared/Toggle';
|
||||||
|
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
|
||||||
|
import { notificationStore } from '@/stores/notifications';
|
||||||
|
|
||||||
|
interface OIDCConfigResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
issuerUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
scopes: string[];
|
||||||
|
usernameClaim: string;
|
||||||
|
emailClaim: string;
|
||||||
|
groupsClaim: string;
|
||||||
|
allowedGroups: string[];
|
||||||
|
allowedDomains: string[];
|
||||||
|
allowedEmails: string[];
|
||||||
|
clientSecretSet: boolean;
|
||||||
|
envOverrides?: Record<string, boolean>;
|
||||||
|
defaultRedirect: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listToString = (values?: string[]) => (values && values.length > 0 ? values.join(', ') : '');
|
||||||
|
const splitList = (input: string) => input.split(/[,\s]+/).map((v) => v.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onConfigUpdated?: (config: OIDCConfigResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OIDCPanel: Component<Props> = (props) => {
|
||||||
|
const [config, setConfig] = createSignal<OIDCConfigResponse | null>(null);
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
const [saving, setSaving] = createSignal(false);
|
||||||
|
const [advancedOpen, setAdvancedOpen] = createSignal(false);
|
||||||
|
|
||||||
|
const [form, setForm] = createStore({
|
||||||
|
enabled: false,
|
||||||
|
issuerUrl: '',
|
||||||
|
clientId: '',
|
||||||
|
redirectUrl: '',
|
||||||
|
scopes: '',
|
||||||
|
usernameClaim: 'preferred_username',
|
||||||
|
emailClaim: 'email',
|
||||||
|
groupsClaim: '',
|
||||||
|
allowedGroups: '',
|
||||||
|
allowedDomains: '',
|
||||||
|
allowedEmails: '',
|
||||||
|
clientSecret: '',
|
||||||
|
clearSecret: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEnvLocked = () => {
|
||||||
|
const env = config()?.envOverrides;
|
||||||
|
return env ? Object.keys(env).length > 0 : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = (data: OIDCConfigResponse | null) => {
|
||||||
|
if (!data) {
|
||||||
|
setForm({
|
||||||
|
enabled: false,
|
||||||
|
issuerUrl: '',
|
||||||
|
clientId: '',
|
||||||
|
redirectUrl: '',
|
||||||
|
scopes: '',
|
||||||
|
usernameClaim: 'preferred_username',
|
||||||
|
emailClaim: 'email',
|
||||||
|
groupsClaim: '',
|
||||||
|
allowedGroups: '',
|
||||||
|
allowedDomains: '',
|
||||||
|
allowedEmails: '',
|
||||||
|
clientSecret: '',
|
||||||
|
clearSecret: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
enabled: data.enabled,
|
||||||
|
issuerUrl: data.issuerUrl ?? '',
|
||||||
|
clientId: data.clientId ?? '',
|
||||||
|
redirectUrl: data.redirectUrl || data.defaultRedirect || '',
|
||||||
|
scopes: data.scopes?.join(' ') ?? 'openid profile email',
|
||||||
|
usernameClaim: data.usernameClaim || 'preferred_username',
|
||||||
|
emailClaim: data.emailClaim || 'email',
|
||||||
|
groupsClaim: data.groupsClaim ?? '',
|
||||||
|
allowedGroups: listToString(data.allowedGroups),
|
||||||
|
allowedDomains: listToString(data.allowedDomains),
|
||||||
|
allowedEmails: listToString(data.allowedEmails),
|
||||||
|
clientSecret: '',
|
||||||
|
clearSecret: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { apiFetch } = await import('@/utils/apiClient');
|
||||||
|
const response = await apiFetch('/api/security/oidc');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load OIDC settings (${response.status})`);
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as OIDCConfigResponse;
|
||||||
|
setConfig(data);
|
||||||
|
resetForm(data);
|
||||||
|
props.onConfigUpdated?.(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OIDCPanel] Failed to load config:', error);
|
||||||
|
notificationStore.error('Failed to load OIDC settings');
|
||||||
|
setConfig(null);
|
||||||
|
resetForm(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async (event?: Event) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (isEnvLocked()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
enabled: form.enabled,
|
||||||
|
issuerUrl: form.issuerUrl.trim(),
|
||||||
|
clientId: form.clientId.trim(),
|
||||||
|
redirectUrl: form.redirectUrl.trim(),
|
||||||
|
scopes: splitList(form.scopes),
|
||||||
|
usernameClaim: form.usernameClaim.trim(),
|
||||||
|
emailClaim: form.emailClaim.trim(),
|
||||||
|
groupsClaim: form.groupsClaim.trim(),
|
||||||
|
allowedGroups: splitList(form.allowedGroups),
|
||||||
|
allowedDomains: splitList(form.allowedDomains),
|
||||||
|
allowedEmails: splitList(form.allowedEmails),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (form.clientSecret.trim() !== '') {
|
||||||
|
payload.clientSecret = form.clientSecret.trim();
|
||||||
|
} else if (form.clearSecret) {
|
||||||
|
payload.clearClientSecret = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiFetch } = await import('@/utils/apiClient');
|
||||||
|
const response = await apiFetch('/api/security/oidc', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || `Failed to save OIDC settings (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = (await response.json()) as OIDCConfigResponse;
|
||||||
|
setConfig(updated);
|
||||||
|
resetForm(updated);
|
||||||
|
notificationStore.success('OIDC settings updated');
|
||||||
|
props.onConfigUpdated?.(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OIDCPanel] Failed to save config:', error);
|
||||||
|
notificationStore.error('Failed to save OIDC settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="none" class="overflow-hidden border border-gray-200 dark:border-gray-700" border={false}>
|
||||||
|
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||||
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 12c0 4.97-4.03 9-9 9m9-9c0-4.97-4.03-9-9-9m9 9H3m9 9c-4.97 0-9-4.03-9-9m9 9c-1.5-1.35-3-4.5-3-9s1.5-7.65 3-9m0 18c1.5-1.35 3-4.5 3-9s-1.5-7.65-3-9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<SectionHeader
|
||||||
|
title="Single sign-on (OIDC)"
|
||||||
|
description="Connect Pulse to your identity provider"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
setForm('enabled', event.currentTarget.checked);
|
||||||
|
}}
|
||||||
|
disabled={isEnvLocked() || loading() || saving()}
|
||||||
|
containerClass="items-center gap-2"
|
||||||
|
label={<span class="text-xs font-medium text-gray-600 dark:text-gray-300">{form.enabled ? 'Enabled' : 'Disabled'}</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="p-6 space-y-5" onSubmit={handleSave}>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||||
|
Loading OIDC settings...
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!loading()}>
|
||||||
|
<Show when={isEnvLocked()}>
|
||||||
|
<div class="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded p-3 text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Managed by environment variables:</strong> OIDC settings are currently defined through environment variables. Edit the deployment configuration to make changes.
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Issuer URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.issuerUrl}
|
||||||
|
onInput={(event) => setForm('issuerUrl', event.currentTarget.value)}
|
||||||
|
placeholder="https://login.example.com/realms/pulse"
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Base issuer URL from your OIDC provider configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.clientId}
|
||||||
|
onInput={(event) => setForm('clientId', event.currentTarget.value)}
|
||||||
|
placeholder="pulse-client"
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class={labelClass('mb-0')}>Client secret</label>
|
||||||
|
<Show when={config()?.clientSecretSet}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-blue-600 hover:underline dark:text-blue-300"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isEnvLocked() && !saving()) {
|
||||||
|
setForm('clientSecret', '');
|
||||||
|
setForm('clearSecret', true);
|
||||||
|
notificationStore.info('Client secret will be cleared on save', 2500);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
>
|
||||||
|
Clear stored secret
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.clientSecret}
|
||||||
|
onInput={(event) => {
|
||||||
|
setForm('clientSecret', event.currentTarget.value);
|
||||||
|
if (event.currentTarget.value.trim() !== '') {
|
||||||
|
setForm('clearSecret', false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={config()?.clientSecretSet ? '•••••••• (leave blank to keep existing)' : 'Enter client secret'}
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Leave blank to keep the existing secret. Use "Clear" to remove it from storage.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Redirect URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={form.redirectUrl}
|
||||||
|
onInput={(event) => setForm('redirectUrl', event.currentTarget.value)}
|
||||||
|
placeholder={config()?.defaultRedirect || ''}
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>If left blank, Pulse will use {config()?.defaultRedirect}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-semibold text-blue-600 hover:underline dark:text-blue-300"
|
||||||
|
onClick={() => setAdvancedOpen(!advancedOpen())}
|
||||||
|
>
|
||||||
|
{advancedOpen() ? 'Hide advanced OIDC options' : 'Show advanced OIDC options'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={advancedOpen()}>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.scopes}
|
||||||
|
onInput={(event) => setForm('scopes', event.currentTarget.value)}
|
||||||
|
placeholder="openid profile email"
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Space-separated list of scopes requested during login.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Username claim</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.usernameClaim}
|
||||||
|
onInput={(event) => setForm('usernameClaim', event.currentTarget.value)}
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Claim used to populate the Pulse username (default: preferred_username).</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Email claim</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.emailClaim}
|
||||||
|
onInput={(event) => setForm('emailClaim', event.currentTarget.value)}
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Groups claim</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.groupsClaim}
|
||||||
|
onInput={(event) => setForm('groupsClaim', event.currentTarget.value)}
|
||||||
|
class={controlClass()}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Optional claim that lists group memberships. Used for group restrictions.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Allowed groups</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={form.allowedGroups}
|
||||||
|
onInput={(event) => setForm('allowedGroups', event.currentTarget.value)}
|
||||||
|
placeholder="admin, sso-admins"
|
||||||
|
class={controlClass('min-h-[70px]')}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Comma or space separated values. Leave empty to allow any group.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Allowed domains</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={form.allowedDomains}
|
||||||
|
onInput={(event) => setForm('allowedDomains', event.currentTarget.value)}
|
||||||
|
placeholder="example.com, partner.io"
|
||||||
|
class={controlClass('min-h-[70px]')}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Restrict access to email domains (without @). Leave empty to allow all.</p>
|
||||||
|
</div>
|
||||||
|
<div class={formField}>
|
||||||
|
<label class={labelClass()}>Allowed email addresses</label>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={form.allowedEmails}
|
||||||
|
onInput={(event) => setForm('allowedEmails', event.currentTarget.value)}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
class={controlClass('min-h-[70px]')}
|
||||||
|
disabled={isEnvLocked() || saving()}
|
||||||
|
/>
|
||||||
|
<p class={formHelpText}>Optional allowlist of specific emails.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 pt-4">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Redirect URL registered with your IdP must match Pulse: {config()?.defaultRedirect || ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
onClick={() => resetForm(config())}
|
||||||
|
disabled={saving() || loading()}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={saving() || loading() || isEnvLocked()}
|
||||||
|
>
|
||||||
|
{saving() ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OIDCPanel;
|
||||||
3
go.mod
3
go.mod
@@ -5,17 +5,20 @@ go 1.24.0
|
|||||||
toolchain go1.24.7
|
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
14
go.sum
@@ -1,8 +1,16 @@
|
|||||||
|
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
365
internal/api/oidc_handlers.go
Normal file
365
internal/api/oidc_handlers.go
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *Router) handleOIDCLogin(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := r.ensureOIDCConfig()
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
writeErrorResponse(w, http.StatusBadRequest, "oidc_disabled", "OIDC authentication is not enabled", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := r.getOIDCService(req.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to initialise OIDC service")
|
||||||
|
writeErrorResponse(w, http.StatusInternalServerError, "oidc_init_failed", "OIDC provider is unavailable", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
ReturnTo string `json:"returnTo"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil && err != io.EOF {
|
||||||
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request payload", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
returnTo := sanitizeOIDCReturnTo(payload.ReturnTo)
|
||||||
|
|
||||||
|
state, entry, err := service.newStateEntry(returnTo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to create OIDC state entry")
|
||||||
|
writeErrorResponse(w, http.StatusInternalServerError, "oidc_state_error", "Unable to start OIDC login", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := service.authCodeURL(state, entry)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"authorizationUrl": authURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleOIDCCallback(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodGet {
|
||||||
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := r.ensureOIDCConfig()
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
http.Error(w, "OIDC is not enabled", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := r.getOIDCService(req.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to initialise OIDC service for callback")
|
||||||
|
r.redirectOIDCError(w, req, "", "oidc_init_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.URL.Query()
|
||||||
|
if errParam := query.Get("error"); errParam != "" {
|
||||||
|
log.Warn().Str("error", errParam).Msg("OIDC provider returned error")
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Provider error: "+errParam)
|
||||||
|
r.redirectOIDCError(w, req, "", errParam)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := query.Get("state")
|
||||||
|
if state == "" {
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing state parameter")
|
||||||
|
r.redirectOIDCError(w, req, "", "missing_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, ok := service.consumeState(state)
|
||||||
|
if !ok {
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Invalid or expired state")
|
||||||
|
r.redirectOIDCError(w, req, "", "invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := query.Get("code")
|
||||||
|
if code == "" {
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing authorization code")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := service.exchangeCode(ctx, code, entry)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("OIDC code exchange failed")
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Code exchange failed")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "exchange_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || rawIDToken == "" {
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Missing ID token")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "missing_id_token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := service.verifier.Verify(ctx, rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to verify ID token")
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "ID token verification failed")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "invalid_id_token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := make(map[string]any)
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to parse ID token claims")
|
||||||
|
LogAuditEvent("oidc_login", "", GetClientIP(req), req.URL.Path, false, "Invalid token claims")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "invalid_claims")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := extractStringClaim(claims, cfg.UsernameClaim)
|
||||||
|
email := extractStringClaim(claims, cfg.EmailClaim)
|
||||||
|
if username == "" {
|
||||||
|
username = email
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = extractStringClaim(claims, "name")
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
username = idToken.Subject
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedEmails) > 0 && !matchesValue(email, cfg.AllowedEmails) {
|
||||||
|
LogAuditEvent("oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email not permitted")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "email_restricted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedDomains) > 0 && !matchesDomain(email, cfg.AllowedDomains) {
|
||||||
|
LogAuditEvent("oidc_login", email, GetClientIP(req), req.URL.Path, false, "Email domain restricted")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "domain_restricted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedGroups) > 0 {
|
||||||
|
groups := extractStringSliceClaim(claims, cfg.GroupsClaim)
|
||||||
|
if !intersects(groups, cfg.AllowedGroups) {
|
||||||
|
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Group restriction failed")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "group_restricted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.establishSession(w, req, username); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to establish session after OIDC login")
|
||||||
|
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, false, "Session creation failed")
|
||||||
|
r.redirectOIDCError(w, req, entry.ReturnTo, "session_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAuditEvent("oidc_login", username, GetClientIP(req), req.URL.Path, true, "OIDC login success")
|
||||||
|
|
||||||
|
target := entry.ReturnTo
|
||||||
|
if target == "" {
|
||||||
|
target = "/"
|
||||||
|
}
|
||||||
|
target = addQueryParam(target, "oidc", "success")
|
||||||
|
http.Redirect(w, req, target, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) getOIDCService(ctx context.Context) (*OIDCService, error) {
|
||||||
|
cfg := r.ensureOIDCConfig()
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
return nil, errors.New("oidc disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.oidcMu.Lock()
|
||||||
|
defer r.oidcMu.Unlock()
|
||||||
|
|
||||||
|
if r.oidcService != nil && r.oidcService.Matches(cfg) {
|
||||||
|
return r.oidcService, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := NewOIDCService(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.oidcService = service
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeOIDCReturnTo(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "//") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) redirectOIDCError(w http.ResponseWriter, req *http.Request, returnTo string, code string) {
|
||||||
|
target := returnTo
|
||||||
|
if target == "" {
|
||||||
|
target = "/"
|
||||||
|
}
|
||||||
|
target = addQueryParam(target, "oidc", "error")
|
||||||
|
if code != "" {
|
||||||
|
target = addQueryParam(target, "oidc_error", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, req, target, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addQueryParam(path, key, value string) string {
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
u, err := url.Parse(path)
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set(key, value)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractStringClaim(claims map[string]any, key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value, ok := claims[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case []string:
|
||||||
|
if len(v) > 0 {
|
||||||
|
return strings.TrimSpace(v[0])
|
||||||
|
}
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range v {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
return strings.TrimSpace(str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractStringSliceClaim(claims map[string]any, key string) []string {
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, ok := claims[key]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []interface{}:
|
||||||
|
out := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
out = append(out, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case string:
|
||||||
|
// Split on commas or spaces
|
||||||
|
parts := strings.FieldsFunc(v, func(r rune) bool {
|
||||||
|
return r == ',' || r == ' '
|
||||||
|
})
|
||||||
|
return parts
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesValue(candidate string, allowed []string) bool {
|
||||||
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||||
|
if candidate == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range allowed {
|
||||||
|
if strings.ToLower(strings.TrimSpace(item)) == candidate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesDomain(email string, allowed []string) bool {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at := strings.LastIndex(email, "@")
|
||||||
|
if at == -1 || at == len(email)-1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
domain := email[at+1:]
|
||||||
|
for _, item := range allowed {
|
||||||
|
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(item), "@"))
|
||||||
|
if normalized != "" && domain == normalized {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func intersects(values []string, allowed []string) bool {
|
||||||
|
if len(values) == 0 || len(allowed) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
allowedSet := make(map[string]struct{}, len(allowed))
|
||||||
|
for _, item := range allowed {
|
||||||
|
allowedSet[strings.ToLower(strings.TrimSpace(item))] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, val := range values {
|
||||||
|
if _, ok := allowedSet[strings.ToLower(strings.TrimSpace(val))]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) ensureOIDCConfig() *config.OIDCConfig {
|
||||||
|
if r.config.OIDC == nil {
|
||||||
|
r.config.OIDC = config.NewOIDCConfig()
|
||||||
|
r.config.OIDC.ApplyDefaults(r.config.PublicURL)
|
||||||
|
}
|
||||||
|
return r.config.OIDC
|
||||||
|
}
|
||||||
220
internal/api/oidc_service.go
Normal file
220
internal/api/oidc_service.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oidcStateTTL defines how long we accept OIDC login attempts before expiring the state entry.
|
||||||
|
const oidcStateTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// OIDCService caches provider metadata and manages transient state for authorization flows.
|
||||||
|
type OIDCService struct {
|
||||||
|
snapshot oidcSnapshot
|
||||||
|
provider *oidc.Provider
|
||||||
|
oauth2Cfg *oauth2.Config
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
stateStore *oidcStateStore
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcSnapshot struct {
|
||||||
|
issuer string
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
redirectURL string
|
||||||
|
scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOIDCService fetches provider metadata and prepares helper structures.
|
||||||
|
func NewOIDCService(ctx context.Context, cfg *config.OIDCConfig) (*OIDCService, error) {
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
return nil, errors.New("oidc is not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to discover OIDC provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2Cfg := &oauth2.Config{
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
ClientSecret: cfg.ClientSecret,
|
||||||
|
RedirectURL: cfg.RedirectURL,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
Scopes: append([]string{}, cfg.Scopes...),
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
|
||||||
|
|
||||||
|
snapshot := oidcSnapshot{
|
||||||
|
issuer: cfg.IssuerURL,
|
||||||
|
clientID: cfg.ClientID,
|
||||||
|
clientSecret: cfg.ClientSecret,
|
||||||
|
redirectURL: cfg.RedirectURL,
|
||||||
|
scopes: append([]string{}, cfg.Scopes...),
|
||||||
|
}
|
||||||
|
|
||||||
|
service := &OIDCService{
|
||||||
|
snapshot: snapshot,
|
||||||
|
provider: provider,
|
||||||
|
oauth2Cfg: oauth2Cfg,
|
||||||
|
verifier: verifier,
|
||||||
|
stateStore: newOIDCStateStore(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches checks whether the cached configuration matches the provided settings.
|
||||||
|
func (s *OIDCService) Matches(cfg *config.OIDCConfig) bool {
|
||||||
|
if s == nil || cfg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.snapshot.issuer != cfg.IssuerURL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.snapshot.clientID != cfg.ClientID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.snapshot.clientSecret != cfg.ClientSecret {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.snapshot.redirectURL != cfg.RedirectURL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s.snapshot.scopes) != len(cfg.Scopes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, scope := range s.snapshot.scopes {
|
||||||
|
if scope != cfg.Scopes[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) newStateEntry(returnTo string) (string, *oidcStateEntry, error) {
|
||||||
|
state, err := generateRandomURLString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
nonce, err := generateRandomURLString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
codeVerifier, codeChallenge, err := generatePKCEPair()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &oidcStateEntry{
|
||||||
|
Nonce: nonce,
|
||||||
|
CodeVerifier: codeVerifier,
|
||||||
|
CodeChallenge: codeChallenge,
|
||||||
|
ReturnTo: returnTo,
|
||||||
|
ExpiresAt: time.Now().Add(oidcStateTTL),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.stateStore.Put(state, entry)
|
||||||
|
return state, entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) consumeState(state string) (*oidcStateEntry, bool) {
|
||||||
|
return s.stateStore.Consume(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) authCodeURL(state string, entry *oidcStateEntry) string {
|
||||||
|
opts := []oauth2.AuthCodeOption{oidc.Nonce(entry.Nonce)}
|
||||||
|
if entry.CodeChallenge != "" {
|
||||||
|
opts = append(opts,
|
||||||
|
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||||
|
oauth2.SetAuthURLParam("code_challenge", entry.CodeChallenge),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return s.oauth2Cfg.AuthCodeURL(state, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) exchangeCode(ctx context.Context, code string, entry *oidcStateEntry) (*oauth2.Token, error) {
|
||||||
|
opts := []oauth2.AuthCodeOption{}
|
||||||
|
if entry.CodeVerifier != "" {
|
||||||
|
opts = append(opts, oauth2.SetAuthURLParam("code_verifier", entry.CodeVerifier))
|
||||||
|
}
|
||||||
|
return s.oauth2Cfg.Exchange(ctx, code, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oidcStateStore keeps short-lived authorization state tokens.
|
||||||
|
type oidcStateStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]*oidcStateEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcStateEntry struct {
|
||||||
|
Nonce string
|
||||||
|
CodeVerifier string
|
||||||
|
CodeChallenge string
|
||||||
|
ReturnTo string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOIDCStateStore() *oidcStateStore {
|
||||||
|
return &oidcStateStore{entries: make(map[string]*oidcStateEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *oidcStateStore) Put(state string, entry *oidcStateEntry) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.entries[state] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *oidcStateStore) Consume(state string) (*oidcStateEntry, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
entry, exists := s.entries[state]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
delete(s.entries, state)
|
||||||
|
|
||||||
|
if time.Now().After(entry.ExpiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomURLString(size int) (string, error) {
|
||||||
|
bytes := make([]byte, size)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePKCEPair() (verifier string, challenge string, err error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(buf); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier = base64.RawURLEncoding.EncodeToString(buf)
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
challenge = base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
return verifier, challenge, nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
157
internal/api/security_oidc.go
Normal file
157
internal/api/security_oidc.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleOIDCConfig exposes and updates the OIDC configuration.
|
||||||
|
func (r *Router) handleOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
r.handleGetOIDCConfig(w, req)
|
||||||
|
case http.MethodPut:
|
||||||
|
r.handleUpdateOIDCConfig(w, req)
|
||||||
|
default:
|
||||||
|
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET and PUT are supported", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleGetOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||||
|
cfg := r.ensureOIDCConfig()
|
||||||
|
|
||||||
|
response := makeOIDCResponse(cfg, r.config.PublicURL)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to encode OIDC configuration response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleUpdateOIDCConfig(w http.ResponseWriter, req *http.Request) {
|
||||||
|
cfg := r.ensureOIDCConfig()
|
||||||
|
|
||||||
|
if len(cfg.EnvOverrides) > 0 {
|
||||||
|
writeErrorResponse(w, http.StatusConflict, "oidc_locked", "OIDC settings are managed via environment variables and cannot be changed at runtime", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IssuerURL string `json:"issuerUrl"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClientSecret *string `json:"clientSecret,omitempty"`
|
||||||
|
RedirectURL string `json:"redirectUrl"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
UsernameClaim string `json:"usernameClaim"`
|
||||||
|
EmailClaim string `json:"emailClaim"`
|
||||||
|
GroupsClaim string `json:"groupsClaim"`
|
||||||
|
AllowedGroups []string `json:"allowedGroups"`
|
||||||
|
AllowedDomains []string `json:"allowedDomains"`
|
||||||
|
AllowedEmails []string `json:"allowedEmails"`
|
||||||
|
ClearClientSecret bool `json:"clearClientSecret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||||
|
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request payload", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := &config.OIDCConfig{
|
||||||
|
Enabled: payload.Enabled,
|
||||||
|
IssuerURL: strings.TrimSpace(payload.IssuerURL),
|
||||||
|
ClientID: strings.TrimSpace(payload.ClientID),
|
||||||
|
RedirectURL: strings.TrimSpace(payload.RedirectURL),
|
||||||
|
Scopes: append([]string{}, payload.Scopes...),
|
||||||
|
UsernameClaim: strings.TrimSpace(payload.UsernameClaim),
|
||||||
|
EmailClaim: strings.TrimSpace(payload.EmailClaim),
|
||||||
|
GroupsClaim: strings.TrimSpace(payload.GroupsClaim),
|
||||||
|
AllowedGroups: append([]string{}, payload.AllowedGroups...),
|
||||||
|
AllowedDomains: append([]string{}, payload.AllowedDomains...),
|
||||||
|
AllowedEmails: append([]string{}, payload.AllowedEmails...),
|
||||||
|
EnvOverrides: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve existing secret unless explicitly changed.
|
||||||
|
updated.ClientSecret = cfg.ClientSecret
|
||||||
|
if payload.ClearClientSecret {
|
||||||
|
updated.ClientSecret = ""
|
||||||
|
}
|
||||||
|
if payload.ClientSecret != nil {
|
||||||
|
updated.ClientSecret = strings.TrimSpace(*payload.ClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.ApplyDefaults(r.config.PublicURL)
|
||||||
|
|
||||||
|
if err := updated.Validate(); err != nil {
|
||||||
|
writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveOIDCConfig(updated); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to persist OIDC configuration")
|
||||||
|
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save OIDC settings", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-memory configuration for immediate effect.
|
||||||
|
r.config.OIDC = updated
|
||||||
|
|
||||||
|
response := makeOIDCResponse(updated, r.config.PublicURL)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to encode OIDC configuration response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IssuerURL string `json:"issuerUrl"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
RedirectURL string `json:"redirectUrl"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
UsernameClaim string `json:"usernameClaim"`
|
||||||
|
EmailClaim string `json:"emailClaim"`
|
||||||
|
GroupsClaim string `json:"groupsClaim"`
|
||||||
|
AllowedGroups []string `json:"allowedGroups"`
|
||||||
|
AllowedDomains []string `json:"allowedDomains"`
|
||||||
|
AllowedEmails []string `json:"allowedEmails"`
|
||||||
|
ClientSecretSet bool `json:"clientSecretSet"`
|
||||||
|
DefaultRedirect string `json:"defaultRedirect"`
|
||||||
|
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeOIDCResponse(cfg *config.OIDCConfig, publicURL string) oidcResponse {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = config.NewOIDCConfig()
|
||||||
|
cfg.ApplyDefaults(publicURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := oidcResponse{
|
||||||
|
Enabled: cfg.Enabled,
|
||||||
|
IssuerURL: cfg.IssuerURL,
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
RedirectURL: cfg.RedirectURL,
|
||||||
|
Scopes: append([]string{}, cfg.Scopes...),
|
||||||
|
UsernameClaim: cfg.UsernameClaim,
|
||||||
|
EmailClaim: cfg.EmailClaim,
|
||||||
|
GroupsClaim: cfg.GroupsClaim,
|
||||||
|
AllowedGroups: append([]string{}, cfg.AllowedGroups...),
|
||||||
|
AllowedDomains: append([]string{}, cfg.AllowedDomains...),
|
||||||
|
AllowedEmails: append([]string{}, cfg.AllowedEmails...),
|
||||||
|
ClientSecretSet: cfg.ClientSecret != "",
|
||||||
|
DefaultRedirect: config.DefaultRedirectURL(publicURL),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.EnvOverrides) > 0 {
|
||||||
|
resp.EnvOverrides = make(map[string]bool, len(cfg.EnvOverrides))
|
||||||
|
for k, v := range cfg.EnvOverrides {
|
||||||
|
resp.EnvOverrides[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -100,6 +100,9 @@ type Config struct {
|
|||||||
ProxyAuthRoleSeparator string `envconfig:"PROXY_AUTH_ROLE_SEPARATOR" default:"|"`
|
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
225
internal/config/oidc.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultOIDCScopes defines the scopes we request when none are provided.
|
||||||
|
var defaultOIDCScopes = []string{"openid", "profile", "email"}
|
||||||
|
|
||||||
|
// DefaultOIDCCallbackPath is the path we expose for the OIDC redirect handler.
|
||||||
|
const DefaultOIDCCallbackPath = "/api/oidc/callback"
|
||||||
|
|
||||||
|
// OIDCConfig captures configuration required to integrate with an OpenID Connect provider.
|
||||||
|
type OIDCConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IssuerURL string `json:"issuerUrl"`
|
||||||
|
ClientID string `json:"clientId"`
|
||||||
|
ClientSecret string `json:"clientSecret,omitempty"`
|
||||||
|
RedirectURL string `json:"redirectUrl"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
UsernameClaim string `json:"usernameClaim,omitempty"`
|
||||||
|
EmailClaim string `json:"emailClaim,omitempty"`
|
||||||
|
GroupsClaim string `json:"groupsClaim,omitempty"`
|
||||||
|
AllowedGroups []string `json:"allowedGroups,omitempty"`
|
||||||
|
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
||||||
|
AllowedEmails []string `json:"allowedEmails,omitempty"`
|
||||||
|
EnvOverrides map[string]bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOIDCConfig returns an instance populated with sensible defaults.
|
||||||
|
func NewOIDCConfig() *OIDCConfig {
|
||||||
|
cfg := &OIDCConfig{}
|
||||||
|
cfg.ApplyDefaults("")
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a deep copy of the configuration.
|
||||||
|
func (c *OIDCConfig) Clone() *OIDCConfig {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clone := *c
|
||||||
|
clone.Scopes = append([]string{}, c.Scopes...)
|
||||||
|
clone.AllowedGroups = append([]string{}, c.AllowedGroups...)
|
||||||
|
clone.AllowedDomains = append([]string{}, c.AllowedDomains...)
|
||||||
|
clone.AllowedEmails = append([]string{}, c.AllowedEmails...)
|
||||||
|
if c.EnvOverrides != nil {
|
||||||
|
clone.EnvOverrides = make(map[string]bool, len(c.EnvOverrides))
|
||||||
|
for k, v := range c.EnvOverrides {
|
||||||
|
clone.EnvOverrides[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDefaults normalises the configuration and injects default values where needed.
|
||||||
|
func (c *OIDCConfig) ApplyDefaults(publicURL string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Scopes) == 0 {
|
||||||
|
c.Scopes = append([]string{}, defaultOIDCScopes...)
|
||||||
|
} else {
|
||||||
|
c.Scopes = normaliseList(c.Scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.UsernameClaim = strings.TrimSpace(c.UsernameClaim); c.UsernameClaim == "" {
|
||||||
|
c.UsernameClaim = "preferred_username"
|
||||||
|
}
|
||||||
|
if c.EmailClaim = strings.TrimSpace(c.EmailClaim); c.EmailClaim == "" {
|
||||||
|
c.EmailClaim = "email"
|
||||||
|
}
|
||||||
|
c.GroupsClaim = strings.TrimSpace(c.GroupsClaim)
|
||||||
|
|
||||||
|
c.AllowedGroups = normaliseList(c.AllowedGroups)
|
||||||
|
c.AllowedDomains = normaliseList(c.AllowedDomains)
|
||||||
|
c.AllowedEmails = normaliseList(c.AllowedEmails)
|
||||||
|
|
||||||
|
if c.EnvOverrides == nil {
|
||||||
|
c.EnvOverrides = make(map[string]bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.RedirectURL) == "" {
|
||||||
|
c.RedirectURL = DefaultRedirectURL(publicURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRedirectURL builds a redirect URL using the provided public base URL.
|
||||||
|
func DefaultRedirectURL(publicURL string) string {
|
||||||
|
if strings.TrimSpace(publicURL) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(publicURL, "/")
|
||||||
|
return base + DefaultOIDCCallbackPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate performs sanity checks and returns the first error encountered.
|
||||||
|
func (c *OIDCConfig) Validate() error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.IssuerURL) == "" {
|
||||||
|
return fmt.Errorf("oidc issuer url is required when OIDC is enabled")
|
||||||
|
}
|
||||||
|
if _, err := url.ParseRequestURI(c.IssuerURL); err != nil {
|
||||||
|
return fmt.Errorf("invalid oidc issuer url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.ClientID) == "" {
|
||||||
|
return fmt.Errorf("oidc client id is required when OIDC is enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(c.RedirectURL) == "" {
|
||||||
|
return fmt.Errorf("oidc redirect url is required when OIDC is enabled")
|
||||||
|
}
|
||||||
|
if _, err := url.ParseRequestURI(c.RedirectURL); err != nil {
|
||||||
|
return fmt.Errorf("invalid oidc redirect url: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.Scopes) == 0 {
|
||||||
|
return fmt.Errorf("oidc scopes must contain at least one entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseList trims entries, removes blanks, and de-duplicates while preserving order.
|
||||||
|
func normaliseList(values []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, raw := range values {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
if _, exists := seen[lower]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[lower] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDelimited converts a delimiter-separated string into a clean slice.
|
||||||
|
func parseDelimited(input string) []string {
|
||||||
|
if strings.TrimSpace(input) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either comma or whitespace separation; replace commas with spaces then split.
|
||||||
|
normalised := strings.ReplaceAll(input, ",", " ")
|
||||||
|
parts := strings.Fields(normalised)
|
||||||
|
return normaliseList(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeFromEnv overrides config values with environment provided pairs.
|
||||||
|
func (c *OIDCConfig) MergeFromEnv(env map[string]string) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.EnvOverrides == nil {
|
||||||
|
c.EnvOverrides = make(map[string]bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := env["OIDC_ENABLED"]; ok {
|
||||||
|
c.Enabled = val == "true" || val == "1"
|
||||||
|
c.EnvOverrides["enabled"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_ISSUER_URL"]; ok {
|
||||||
|
c.IssuerURL = val
|
||||||
|
c.EnvOverrides["issuerUrl"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_CLIENT_ID"]; ok {
|
||||||
|
c.ClientID = val
|
||||||
|
c.EnvOverrides["clientId"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_CLIENT_SECRET"]; ok {
|
||||||
|
c.ClientSecret = val
|
||||||
|
c.EnvOverrides["clientSecret"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_REDIRECT_URL"]; ok {
|
||||||
|
c.RedirectURL = val
|
||||||
|
c.EnvOverrides["redirectUrl"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_SCOPES"]; ok {
|
||||||
|
c.Scopes = parseDelimited(val)
|
||||||
|
c.EnvOverrides["scopes"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_USERNAME_CLAIM"]; ok {
|
||||||
|
c.UsernameClaim = val
|
||||||
|
c.EnvOverrides["usernameClaim"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_EMAIL_CLAIM"]; ok {
|
||||||
|
c.EmailClaim = val
|
||||||
|
c.EnvOverrides["emailClaim"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_GROUPS_CLAIM"]; ok {
|
||||||
|
c.GroupsClaim = val
|
||||||
|
c.EnvOverrides["groupsClaim"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_ALLOWED_GROUPS"]; ok {
|
||||||
|
c.AllowedGroups = parseDelimited(val)
|
||||||
|
c.EnvOverrides["allowedGroups"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_ALLOWED_DOMAINS"]; ok {
|
||||||
|
c.AllowedDomains = parseDelimited(val)
|
||||||
|
c.EnvOverrides["allowedDomains"] = true
|
||||||
|
}
|
||||||
|
if val, ok := env["OIDC_ALLOWED_EMAILS"]; ok {
|
||||||
|
c.AllowedEmails = parseDelimited(val)
|
||||||
|
c.EnvOverrides["allowedEmails"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type ConfigPersistence struct {
|
|||||||
webhookFile string
|
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
36
scripts/dev/start-oidc-mock.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Simple helper that spins up a local Dex server to act as a mock OIDC provider for dev/testing.
|
||||||
|
# Requires Docker. The container exposes the issuer on http://127.0.0.1:5556/dex
|
||||||
|
# and registers a static client `pulse-dev` with secret `pulse-secret`.
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
CONFIG_FILE="$PROJECT_ROOT/dev/oidc/dex-config.yaml"
|
||||||
|
CONTAINER_NAME="pulse-oidc-mock"
|
||||||
|
DEX_IMAGE="ghcr.io/dexidp/dex:v2.38.0"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "docker is required to run the mock OIDC provider" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "missing Dex config at $CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop an existing container if it is already running
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
echo "Stopping existing ${CONTAINER_NAME} container..."
|
||||||
|
docker rm -f "$CONTAINER_NAME" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting Dex mock OIDC provider on http://127.0.0.1:5556/dex"
|
||||||
|
docker run \
|
||||||
|
--rm \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-p 5556:5556 \
|
||||||
|
-v "$CONFIG_FILE:/etc/dex/config.yaml:ro" \
|
||||||
|
"$DEX_IMAGE" \
|
||||||
|
dex serve /etc/dex/config.yaml
|
||||||
Reference in New Issue
Block a user