diff --git a/backend/src/index.js b/backend/src/index.js index aaa1a9c..b393fb1 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -17,6 +17,7 @@ const diag = require('./diag'); const recordsRouter = require('./routes/records'); const settingsRouter = require('./routes/settings'); const authRouter = require('./routes/auth'); +const oidcRouter = require('./routes/oidc'); const usersRouter = require('./routes/users'); const db = require('./db'); @@ -29,6 +30,7 @@ app.use(express.json()); // ─── Public routes (no auth required) ──────────────────────────────────────── app.get('/api/ping', (req, res) => res.json({ ok: true })); +app.use('/api/auth/oidc', oidcRouter); // must be before /api/auth app.use('/api/auth', authRouter); // ─── Protected routes (JWT required) ───────────────────────────────────────── diff --git a/backend/src/routes/oidc.js b/backend/src/routes/oidc.js new file mode 100644 index 0000000..e6963e6 --- /dev/null +++ b/backend/src/routes/oidc.js @@ -0,0 +1,117 @@ +/** + * Authentik OIDC SSO routes + * + * Requires env: + * AUTHENTIK_URL Base URL of your Authentik instance, e.g. https://auth.example.com + * AUTHENTIK_CLIENT_ID OAuth2 application client ID + * AUTHENTIK_CLIENT_SECRET OAuth2 application client secret + * + * Endpoints: + * GET /api/auth/oidc/config — public, returns OIDC config for the frontend + * POST /api/auth/oidc/callback — exchanges code for token, returns a local JWT + */ + +const express = require('express'); +const router = express.Router(); +const { signToken } = require('../auth'); +const users = require('../users'); + +// ─── Discovery doc cache ────────────────────────────────────────────────────── + +let _discovery = null; +let _discoveryTime = 0; +const DISCOVERY_TTL_MS = 5 * 60 * 1000; // 5 minutes + +async function getDiscovery() { + if (_discovery && Date.now() - _discoveryTime < DISCOVERY_TTL_MS) return _discovery; + const base = (process.env.AUTHENTIK_URL || '').replace(/\/$/, ''); + if (!base) throw new Error('AUTHENTIK_URL is not configured'); + const res = await fetch(`${base}/application/o/.well-known/openid-configuration`); + if (!res.ok) throw new Error(`Authentik discovery fetch failed: HTTP ${res.status}`); + _discovery = await res.json(); + _discoveryTime = Date.now(); + return _discovery; +} + +function isEnabled() { + return !!(process.env.AUTHENTIK_URL && process.env.AUTHENTIK_CLIENT_ID && process.env.AUTHENTIK_CLIENT_SECRET); +} + +// ─── GET /config ────────────────────────────────────────────────────────────── +// Public — called by the login page to decide whether to show the SSO button. + +router.get('/config', async (req, res) => { + if (!isEnabled()) return res.json({ enabled: false }); + try { + const doc = await getDiscovery(); + res.json({ + enabled: true, + authorizationEndpoint: doc.authorization_endpoint, + clientId: process.env.AUTHENTIK_CLIENT_ID, + scope: 'openid profile email', + }); + } catch (err) { + console.error('[oidc] config error:', err.message); + res.json({ enabled: false, error: err.message }); + } +}); + +// ─── POST /callback ─────────────────────────────────────────────────────────── +// Accepts { code, redirectUri } — exchanges for tokens, finds/creates user, +// returns { token, user } matching the standard /api/auth/login response shape. + +router.post('/callback', async (req, res) => { + if (!isEnabled()) return res.status(400).json({ error: 'SSO is not configured' }); + const { code, redirectUri } = req.body; + if (!code) return res.status(400).json({ error: 'code is required' }); + if (!redirectUri) return res.status(400).json({ error: 'redirectUri is required' }); + + try { + const doc = await getDiscovery(); + + // Exchange authorization code for tokens + const tokenRes = await fetch(doc.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: process.env.AUTHENTIK_CLIENT_ID, + client_secret: process.env.AUTHENTIK_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + }), + }); + const tokenData = await tokenRes.json(); + if (tokenData.error) { + return res.status(401).json({ error: tokenData.error_description ?? tokenData.error }); + } + + // Fetch user info + const userinfoRes = await fetch(doc.userinfo_endpoint, { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + if (!userinfoRes.ok) return res.status(401).json({ error: 'Failed to fetch user info from Authentik' }); + const info = await userinfoRes.json(); + + // Prefer email as username, fall back to preferred_username or sub + const username = (info.preferred_username || info.email || info.sub || '').toLowerCase().trim(); + if (!username) return res.status(401).json({ error: 'Could not determine username from Authentik profile' }); + + // Find existing user or auto-create + let user = await users.findByUsername(username); + if (!user) { + // Auto-create with a random unusable password + const { randomBytes } = require('crypto'); + const randomPw = randomBytes(32).toString('hex'); + user = await users.create(username, randomPw); + } + + const token = signToken(user); + res.json({ token, user: { id: user.id, username: user.username } }); + } catch (err) { + console.error('[oidc] callback error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/frontend/src/App.css b/frontend/src/App.css index 28a4699..458560d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -731,6 +731,48 @@ body { .login-btn { width: 100%; padding: 10px; font-size: 14px; margin-top: 4px; } +/* SSO button */ +.btn-sso { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 14px; + font-size: 14px; + font-weight: 500; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.btn-sso:hover { + background: var(--hover-bg, rgba(128,128,128,0.08)); + border-color: var(--accent); +} +.btn-sso:before { + content: '🔑'; + font-size: 16px; +} + +.login-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0 10px; + color: var(--text-muted); + font-size: 12px; +} +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + .login-footer { margin-top: 24px; font-size: 12px; diff --git a/frontend/src/api/dns.js b/frontend/src/api/dns.js index 64d4f8a..d9f51eb 100644 --- a/frontend/src/api/dns.js +++ b/frontend/src/api/dns.js @@ -54,6 +54,22 @@ export async function changePassword(currentPassword, newPassword) { })); } +// ─── OIDC / SSO ────────────────────────────────────────────────────────────── + +export async function getOidcConfig() { + const res = await fetch(`${BASE}/auth/oidc/config`); + return handleResponse(res); +} + +export async function oidcCallback(code, redirectUri) { + const res = await fetch(`${BASE}/auth/oidc/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, redirectUri }), + }); + return handleResponse(res); +} + // ─── Users ──────────────────────────────────────────────────────────────────── export async function getUsers() { diff --git a/frontend/src/components/LoginPage.js b/frontend/src/components/LoginPage.js index 01a9b69..1d3878f 100644 --- a/frontend/src/components/LoginPage.js +++ b/frontend/src/components/LoginPage.js @@ -1,5 +1,12 @@ -import { useState } from 'react'; -import { login, setToken } from '../api/dns'; +import { useState, useEffect } from 'react'; +import { login, setToken, getOidcConfig, oidcCallback } from '../api/dns'; + +const OIDC_STATE_KEY = 'sloth_oidc_state'; + +function getRedirectUri() { + // Strip any existing query/hash — the redirect_uri must be the clean app URL + return `${window.location.origin}${window.location.pathname}`; +} export default function LoginPage({ onLogin }) { const [username, setUsername] = useState(''); @@ -7,6 +14,69 @@ export default function LoginPage({ onLogin }) { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + // OIDC state + const [oidc, setOidc] = useState(null); // null = loading, false = disabled + const [oidcLoading, setOidcLoading] = useState(false); + + // 1. Fetch OIDC config on mount + useEffect(() => { + getOidcConfig() + .then(cfg => setOidc(cfg.enabled ? cfg : false)) + .catch(() => setOidc(false)); + }, []); + + // 2. Handle redirect back from Authentik (?code=...&state=...) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + const state = params.get('state'); + if (!code) return; + + // Verify state to prevent CSRF + const savedState = sessionStorage.getItem(OIDC_STATE_KEY); + sessionStorage.removeItem(OIDC_STATE_KEY); + if (state && savedState && state !== savedState) { + setError('SSO state mismatch — please try again'); + // Remove code from URL + window.history.replaceState({}, '', window.location.pathname); + return; + } + + // Exchange code for JWT + setOidcLoading(true); + setError(''); + const redirectUri = getRedirectUri(); + // Clean URL before async work so a page refresh won't re-attempt + window.history.replaceState({}, '', window.location.pathname); + + oidcCallback(code, redirectUri) + .then(data => { + setToken(data.token); + onLogin(data.user); + }) + .catch(err => { + setError(err.message); + setOidcLoading(false); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // 3. Initiate Authentik redirect + function handleSsoClick() { + if (!oidc) return; + const state = crypto.randomUUID(); + sessionStorage.setItem(OIDC_STATE_KEY, state); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: oidc.clientId, + redirect_uri: getRedirectUri(), + scope: oidc.scope, + state, + }); + window.location.href = `${oidc.authorizationEndpoint}?${params}`; + } + + // 4. Local login async function handleSubmit(e) { e.preventDefault(); setError(''); @@ -22,11 +92,39 @@ export default function LoginPage({ onLogin }) { } } + // While exchanging an OIDC code, show a minimal loading screen + if (oidcLoading) { + return ( +
Signing in with Authentik…
+