diff --git a/README.md b/README.md index 6ba5fde..9b16c3f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.16.12_Beta-blue) +![Version](https://img.shields.io/badge/version-0.17.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -279,6 +279,7 @@ Instant search across devices, clients, and events from the top navigation bar. - Admin-only user creation and role assignment - JWT authentication with secure session handling - **Two-factor authentication (TOTP)** — per-user 2FA setup via QR code (compatible with Google Authenticator, Authy, etc.); login requires a 6-digit code after password when enabled; disable with password confirmation +- **Single sign-on (OIDC/SSO)** — authenticate against any standards-compliant OpenID Connect provider (Microsoft Entra ID, Okta, Google Workspace, Keycloak, Authentik, …). Auto-provisions or links users on first sign-in, maps IdP groups to platform roles, and coexists with local login as a break-glass fallback. Configured entirely in the UI — see [Single sign-on (OIDC)](#single-sign-on-oidc) below - **Credential preset access control** — presets can be restricted to admins only (`allow_operator_use`); operators only see presets they are permitted to use when adding or updating devices ### TLS / HTTPS @@ -470,6 +471,40 @@ Upgrades are non-breaking: on startup, existing ciphertext is decrypted via a ** **Key rotation:** set a new `ENCRYPTION_KEY` (or delete the persisted secret to force regeneration) and restart. Old rows keep decrypting via the legacy fallback and are re-encrypted forward automatically. **If the persisted secret and any prior key are both lost**, ciphertext under that key cannot be recovered — re-enter device credentials or restore a backup. +### Single sign-on (OIDC) + +Sign in against any standards-compliant OpenID Connect provider (Microsoft Entra ID, Okta, Google Workspace, Keycloak, Authentik, and others). It uses the Authorization Code flow with PKCE; the server validates the ID token (signature via JWKS, plus `iss`/`aud`/`exp`/`nonce`) and then issues the platform's normal session — so SSO users get the same experience as local users. **No environment variables or redeploy are required** — everything is configured in the UI and stored (with the client secret encrypted at rest) in the database. + +**1. Register the app with your identity provider.** Create an OIDC/OAuth "web application" and note its **Issuer URL** (the base that serves `/.well-known/openid-configuration`), **Client ID**, and **Client Secret** (public clients with no secret are also supported via PKCE). For the **redirect / callback URI**, use the value shown in the settings panel: + +``` +https:///api/auth/oidc/callback +``` + +**2. Configure it in the app.** As an admin, go to **Settings → SSO / OIDC** and fill in: + +| Field | Purpose | +|---|---| +| **Issuer URL** | Your provider's base URL; **Test discovery** verifies it's reachable. | +| **Client ID / Client Secret** | From step 1. The secret is write-only and encrypted at rest; leave blank to keep the existing one. | +| **Scopes** | Default `openid profile email`. Add a groups scope if your IdP needs one to emit group membership. | +| **Username / Email / Groups claim** | Which ID-token claims to read. Defaults: `preferred_username`, `email`, `groups`. | +| **Group → role mapping** | Map IdP groups to **admin / operator / viewer** (e.g. `net-admins → admin`). Highest-privilege match wins. | +| **Default role** | Role for users whose groups don't match any mapping (default **viewer**). | +| **Auto-provision** | Create a local user automatically on first sign-in. | +| **Link by verified email** | Attach an SSO identity to an existing local account when the IdP's `email_verified` matches. | +| **Allowed email domains** | Optional allowlist restricting who may sign in. | +| **Button label** | Text on the login button (e.g. "Sign in with Okta"). | + +Enable it and **Save**. A **Sign in with SSO** button appears on the login page. + +**Behavior and safety:** +- **Provisioning / linking** — first sign-in creates a user (auto-provision) or, if `email_verified` matches an existing local account, links to it (no duplicate). SSO users have no local password. +- **Roles** are always resolved server-side from your group mapping; existing users are never silently demoted when a login carries no matching group. +- **Break-glass** — local username/password login stays available, so the seeded local admin can always get in even if the IdP is misconfigured or unreachable. +- **Behind a proxy / custom domain** — the redirect URI is derived from the request host; set **Public base URL** in the panel to pin it if your external URL differs from what the backend sees. +- SSO users are labeled with an **SSO** badge in **Users & Roles**. + --- ## Updating diff --git a/backend/package-lock.json b/backend/package-lock.json index 948c09b..069f16a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.6-beta", + "version": "0.16.12-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.16.6-beta", + "version": "0.16.12-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "nodemailer": "^9.0.1", + "openid-client": "^5.7.1", "otpauth": "^9.5.1", "pg": "^8.21.0", "qrcode": "^1.5.4", @@ -5865,6 +5866,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6517,6 +6527,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6529,6 +6548,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6576,6 +6604,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/backend/package.json b/backend/package.json index c77d1fe..c87db9c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.12-beta", + "version": "0.17.0-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { @@ -27,6 +27,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "nodemailer": "^9.0.1", + "openid-client": "^5.7.1", "otpauth": "^9.5.1", "pg": "^8.21.0", "qrcode": "^1.5.4", diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 3eabc76..d333cbd 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -453,6 +453,16 @@ CREATE TABLE IF NOT EXISTS config_templates ( ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64); ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false; +-- OIDC / SSO identity mapping. Local users keep password_hash; SSO users are +-- keyed by (oidc_issuer, oidc_subject) and have a null password_hash. +ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(20) NOT NULL DEFAULT 'local'; +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users (oidc_issuer, oidc_subject) WHERE oidc_subject IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email)) WHERE email IS NOT NULL; + -- Per-client daily traffic rollups from the NetFlow collector. mac_address -- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other' -- (clients folded by the top-N cardinality cap). diff --git a/backend/src/index.ts b/backend/src/index.ts index a5af0a7..1d37471 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -32,6 +32,7 @@ import { decrypt } from './utils/crypto'; import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins'; import authRoutes from './routes/auth'; +import oidcRoutes from './routes/oidc'; import auditLogRoutes from './routes/auditLog'; import tagsRoutes from './routes/tags'; import maintenanceWindowsRoutes from './routes/maintenanceWindows'; @@ -296,6 +297,7 @@ app.get('/api/health', (_req, res) => { }); // ─── Routes ─────────────────────────────────────────────────────────────────── +app.use('/api/auth/oidc', oidcRoutes); app.use('/api/auth', authRoutes); app.use('/api/devices', devicesRoutes); app.use('/api/clients', clientsRoutes); diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts new file mode 100644 index 0000000..7a31d6c --- /dev/null +++ b/backend/src/routes/oidc.ts @@ -0,0 +1,134 @@ +/** + * OIDC / SSO endpoints, mounted at /api/auth/oidc. + * + * Public: GET /status, GET /login, GET /callback (the browser-facing flow). + * Admin: GET/PUT /config, POST /test (provider configuration). + * + * The callback mints the platform's normal session JWT and hands it to the SPA + * via the URL fragment, matching the existing localStorage/Bearer model. + */ +import { Router, Request, Response } from 'express'; +import { Issuer } from 'openid-client'; +import { requireAuth, requireAdmin, signToken } from '../middleware/auth'; +import { rateLimitRedis } from '../middleware/rateLimitRedis'; +import { + loadOidcConfig, saveOidcConfig, maskedConfig, APP_ROLES, type AppRole, type OidcConfig, +} from '../services/oidc/oidcConfig'; +import { beginLogin, completeLogin, resetOidcClientCache } from '../services/oidc/OidcService'; + +const router = Router(); + +function redirectUriFor(req: Request, config: OidcConfig): string { + const base = config.public_base_url + ? config.public_base_url.replace(/\/$/, '') + : `${req.protocol}://${req.get('host')}`; + return `${base}/api/auth/oidc/callback`; +} + +// Only accept same-site relative paths for post-login navigation. Rejects +// absolute URLs and protocol-relative values (//host) to avoid open redirects. +function safeReturnTo(value: unknown): string { + return typeof value === 'string' && /^\/(?!\/)[^\\\s]*$/.test(value) ? value : '/dashboard'; +} + +// ─── Public: is SSO available? (drives the login button) ──────────────────────── +router.get('/status', async (_req: Request, res: Response) => { + const config = await loadOidcConfig(); + res.json({ + enabled: !!(config.enabled && config.issuer_url && config.client_id), + button_label: config.button_label || 'Sign in with SSO', + }); +}); + +// ─── Public: start the login flow ─────────────────────────────────────────────── +router.get( + '/login', + rateLimitRedis({ windowSec: 60, max: 20, keyPrefix: 'oidc-login', allMethods: true }), + async (req: Request, res: Response) => { + try { + const config = await loadOidcConfig(); + const returnTo = safeReturnTo(req.query.returnTo); + const url = await beginLogin(redirectUriFor(req, config), returnTo); + res.redirect(url); + } catch (e) { + res.redirect(`/login?error=sso&reason=${encodeURIComponent((e as Error).message)}`); + } + } +); + +// ─── Public: IdP redirect target ──────────────────────────────────────────────── +router.get( + '/callback', + rateLimitRedis({ windowSec: 60, max: 30, keyPrefix: 'oidc-callback', allMethods: true }), + async (req: Request, res: Response) => { + try { + const params = req.query as Record; + if (params.error) throw new Error(params.error_description || params.error); + const { user, returnTo } = await completeLogin(params); + const token = signToken({ userId: user.id, username: user.username, role: user.role }); + const dest = safeReturnTo(returnTo); + // Hand the token to the SPA via fragment (never sent to the server/logs). + res.redirect(`/auth/callback#token=${encodeURIComponent(token)}&returnTo=${encodeURIComponent(dest)}`); + } catch (e) { + res.redirect(`/login?error=sso&reason=${encodeURIComponent((e as Error).message)}`); + } + } +); + +// ─── Admin: read config (secret masked) ───────────────────────────────────────── +router.get('/config', requireAuth, requireAdmin, async (req: Request, res: Response) => { + const config = await loadOidcConfig(); + res.json({ ...maskedConfig(config), redirect_uri: redirectUriFor(req, config) }); +}); + +// ─── Admin: update config ─────────────────────────────────────────────────────── +router.put('/config', requireAuth, requireAdmin, async (req: Request, res: Response) => { + const body = req.body as Partial & { client_secret?: string }; + + const validRole = (r: unknown): r is AppRole => APP_ROLES.includes(r as AppRole); + if (body.default_role !== undefined && !validRole(body.default_role)) { + return res.status(400).json({ error: 'Invalid default_role' }); + } + if (body.group_role_map !== undefined) { + if (typeof body.group_role_map !== 'object' || body.group_role_map === null) { + return res.status(400).json({ error: 'group_role_map must be an object' }); + } + for (const r of Object.values(body.group_role_map)) { + if (!validRole(r)) return res.status(400).json({ error: `Invalid role in group_role_map: ${String(r)}` }); + } + } + if (body.enabled && body.issuer_url !== undefined) { + try { new URL(body.issuer_url); } catch { return res.status(400).json({ error: 'issuer_url must be a valid URL' }); } + } + + // client_secret handling: undefined = keep, '' = clear, string = set. + const { client_secret, ...patch } = body; + delete (patch as Record).client_secret_encrypted; // never accept ciphertext from the client + delete (patch as Record).has_secret; + + const saved = await saveOidcConfig(patch, client_secret); + resetOidcClientCache(); + res.json({ ...maskedConfig(saved), redirect_uri: redirectUriFor(req, saved) }); +}); + +// ─── Admin: validate discovery against the configured issuer ──────────────────── +router.post('/test', requireAuth, requireAdmin, async (req: Request, res: Response) => { + const issuerUrl = (req.body?.issuer_url as string) || (await loadOidcConfig()).issuer_url; + if (!issuerUrl) return res.status(400).json({ error: 'issuer_url is required' }); + try { + const issuer = await Issuer.discover(issuerUrl); + res.json({ + ok: true, + issuer: issuer.metadata.issuer, + authorization_endpoint: issuer.metadata.authorization_endpoint, + token_endpoint: issuer.metadata.token_endpoint, + userinfo_endpoint: issuer.metadata.userinfo_endpoint, + scopes_supported: issuer.metadata.scopes_supported, + claims_supported: issuer.metadata.claims_supported, + }); + } catch (e) { + res.status(400).json({ ok: false, error: `Discovery failed: ${(e as Error).message}` }); + } +}); + +export default router; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 08a0620..b8f9ed1 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -4,15 +4,22 @@ import { query, queryOne } from '../config/database'; import { requireAuth, requireAdmin } from '../middleware/auth'; import { netflowCollector } from '../services/netflow/NetflowCollector'; import { validatePassword } from '../utils/passwordPolicy'; +import { OIDC_SETTINGS_KEY } from '../services/oidc/oidcConfig'; const router = Router(); router.use(requireAuth); +// Settings keys managed through their own dedicated (admin-only) endpoints and +// never exposed via this generic surface — the OIDC config holds an encrypted +// client secret and must only be read/written through /api/auth/oidc/config. +const PROTECTED_SETTING_KEYS = new Set([OIDC_SETTINGS_KEY]); + // GET /api/settings router.get('/', async (_req: Request, res: Response) => { const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`); const map: Record = {}; for (const s of settings as { key: string; value: unknown }[]) { + if (PROTECTED_SETTING_KEYS.has(s.key)) continue; map[s.key] = s.value; } res.json(map); @@ -22,6 +29,7 @@ router.get('/', async (_req: Request, res: Response) => { router.put('/', requireAdmin, async (req: Request, res: Response) => { const updates = req.body as Record; for (const [key, value] of Object.entries(updates)) { + if (PROTECTED_SETTING_KEYS.has(key)) continue; await query( `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, @@ -39,7 +47,7 @@ router.put('/', requireAdmin, async (req: Request, res: Response) => { // GET /api/settings/users router.get('/users', requireAdmin, async (_req: Request, res: Response) => { const users = await query( - `SELECT id, username, role, created_at FROM users ORDER BY username` + `SELECT id, username, email, role, auth_provider, created_at FROM users ORDER BY username` ); res.json(users); }); diff --git a/backend/src/services/oidc/OidcService.ts b/backend/src/services/oidc/OidcService.ts new file mode 100644 index 0000000..19e4d97 --- /dev/null +++ b/backend/src/services/oidc/OidcService.ts @@ -0,0 +1,193 @@ +/** + * OIDC Authorization-Code (PKCE) flow orchestration. + * + * beginLogin() builds the authorize URL and stashes the per-request PKCE/nonce + * transaction in Redis (short TTL, single-use). completeLogin() validates the + * callback (openid-client checks the ID-token signature via JWKS plus iss/aud/ + * exp/nonce and the PKCE verifier), then resolves the identity to a local user. + */ +import { Issuer, generators } from 'openid-client'; +import type { Client, TokenSet } from 'openid-client'; +import { createHash } from 'crypto'; +import { redis } from '../../config/redis'; +import { query, queryOne } from '../../config/database'; +import { loadOidcConfig, getClientSecret, type OidcConfig, type AppRole } from './oidcConfig'; +import { + extractGroups, mapGroupsToRole, deriveUsername, getEmail, isEmailVerified, emailDomainAllowed, +} from './claimMapping'; + +const TX_TTL_SECONDS = 600; // 10 minutes +const txKey = (state: string): string => `oidc:tx:${state}`; + +export interface ResolvedUser { + id: number; + username: string; + role: AppRole; +} + +interface Transaction { + nonce: string; + code_verifier: string; + redirectUri: string; + returnTo: string; +} + +// Cache the discovered client; rebuild when the relevant config or redirect URI changes. +let clientCache: { key: string; client: Client } | null = null; + +function configFingerprint(config: OidcConfig, redirectUri: string): string { + return createHash('sha256') + .update([config.issuer_url, config.client_id, config.client_secret_encrypted ?? '', redirectUri].join('|')) + .digest('hex'); +} + +async function getClient(config: OidcConfig, redirectUri: string): Promise { + if (!config.issuer_url || !config.client_id) throw new Error('OIDC is not fully configured'); + const key = configFingerprint(config, redirectUri); + if (clientCache && clientCache.key === key) return clientCache.client; + + const issuer = await Issuer.discover(config.issuer_url); + const secret = getClientSecret(config); + const client = new issuer.Client({ + client_id: config.client_id, + client_secret: secret ?? undefined, + redirect_uris: [redirectUri], + response_types: ['code'], + // Public client (no secret) must use PKCE-only token auth. + token_endpoint_auth_method: secret ? 'client_secret_basic' : 'none', + }); + clientCache = { key, client }; + return client; +} + +/** Invalidate the cached client after a config change. */ +export function resetOidcClientCache(): void { + clientCache = null; +} + +export async function beginLogin(redirectUri: string, returnTo: string): Promise { + const config = await loadOidcConfig(); + if (!config.enabled) throw new Error('OIDC login is not enabled'); + const client = await getClient(config, redirectUri); + + const state = generators.state(); + const nonce = generators.nonce(); + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); + + const tx: Transaction = { nonce, code_verifier, redirectUri, returnTo }; + await redis.set(txKey(state), JSON.stringify(tx), 'EX', TX_TTL_SECONDS); + + return client.authorizationUrl({ + scope: config.scopes || 'openid profile email', + state, + nonce, + code_challenge, + code_challenge_method: 'S256', + }); +} + +export async function completeLogin(params: Record): Promise<{ user: ResolvedUser; returnTo: string }> { + const state = params.state; + if (!state) throw new Error('Missing state'); + + const raw = await redis.get(txKey(state)); + if (!raw) throw new Error('Login session expired or invalid — please try again'); + await redis.del(txKey(state)); // single use + const tx: Transaction = JSON.parse(raw); + + const config = await loadOidcConfig(); + const client = await getClient(config, tx.redirectUri); + + const tokenSet: TokenSet = await client.callback(tx.redirectUri, params, { + code_verifier: tx.code_verifier, + state, + nonce: tx.nonce, + }); + + let claims: Record = { ...tokenSet.claims() }; + // Some IdPs put groups only in the userinfo response, not the ID token. + if (!claims[config.groups_claim] && tokenSet.access_token) { + try { + const userinfo = await client.userinfo(tokenSet); + claims = { ...claims, ...userinfo }; + } catch { /* userinfo optional */ } + } + + const user = await resolveUser(claims, config); + return { user, returnTo: tx.returnTo }; +} + +async function resolveUser(claims: Record, config: OidcConfig): Promise { + const sub = String(claims.sub ?? ''); + const issuer = String(claims.iss ?? ''); + if (!sub || !issuer) throw new Error('ID token missing sub/iss'); + + const email = getEmail(claims, config); + const mappedRole = mapGroupsToRole(extractGroups(claims, config.groups_claim), config.group_role_map); + + if (!emailDomainAllowed(email, config.allowed_email_domains)) { + throw new Error('Your email domain is not permitted to sign in'); + } + + // 1. Already-linked user → authenticate, sync role only when a group maps + // (never auto-demote to the default when no group matches). + const linked = await queryOne<{ id: number; username: string; role: AppRole }>( + `SELECT id, username, role FROM users WHERE oidc_issuer = $1 AND oidc_subject = $2`, + [issuer, sub] + ); + if (linked) { + const role = mappedRole ?? linked.role; + if (role !== linked.role) { + await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, linked.id]); + } + if (email) await query(`UPDATE users SET email = $1 WHERE id = $2`, [email, linked.id]).catch(() => {}); + return { id: linked.id, username: linked.username, role }; + } + + // 2. Link to an existing local account by verified email + if (config.link_by_verified_email && email && isEmailVerified(claims)) { + const byEmail = await queryOne<{ id: number; username: string; role: AppRole }>( + `SELECT id, username, role FROM users WHERE lower(email) = lower($1) AND oidc_subject IS NULL`, + [email] + ); + if (byEmail) { + const role = mappedRole ?? byEmail.role; // preserve existing role if no group maps + await query( + `UPDATE users SET oidc_issuer = $1, oidc_subject = $2, auth_provider = 'oidc', role = $3 WHERE id = $4`, + [issuer, sub, role, byEmail.id] + ); + return { id: byEmail.id, username: byEmail.username, role }; + } + } + + // 3. Auto-provision a new SSO user + if (config.auto_provision) { + const role = mappedRole ?? config.default_role; + const username = await uniqueUsername(deriveUsername(claims, config)); + const created = await queryOne<{ id: number; username: string; role: AppRole }>( + `INSERT INTO users (username, email, role, auth_provider, oidc_issuer, oidc_subject) + VALUES ($1, $2, $3, 'oidc', $4, $5) + RETURNING id, username, role`, + [username, email, role, issuer, sub] + ); + if (!created) throw new Error('Failed to provision user'); + return created; + } + + // 4. No account and provisioning disabled + throw new Error('No account exists for this identity, and automatic provisioning is disabled'); +} + +async function uniqueUsername(base: string): Promise { + const root = (base || 'user').slice(0, 50); + const existing = await queryOne(`SELECT 1 FROM users WHERE username = $1`, [root]); + if (!existing) return root; + for (let i = 2; i <= 50; i++) { + const suffix = `-${i}`; + const candidate = root.slice(0, 50 - suffix.length) + suffix; + const clash = await queryOne(`SELECT 1 FROM users WHERE username = $1`, [candidate]); + if (!clash) return candidate; + } + return `${root.slice(0, 40)}-${Date.now().toString().slice(-6)}`; +} diff --git a/backend/src/services/oidc/__tests__/claimMapping.test.ts b/backend/src/services/oidc/__tests__/claimMapping.test.ts new file mode 100644 index 0000000..68cc300 --- /dev/null +++ b/backend/src/services/oidc/__tests__/claimMapping.test.ts @@ -0,0 +1,71 @@ +import { + extractGroups, mapGroupsToRole, deriveUsername, getEmail, isEmailVerified, emailDomainAllowed, +} from '../claimMapping'; +import { DEFAULT_OIDC_CONFIG, type AppRole } from '../oidcConfig'; + +const cfg = (over = {}) => ({ ...DEFAULT_OIDC_CONFIG, ...over }); + +describe('extractGroups', () => { + it('reads an array claim', () => { + expect(extractGroups({ groups: ['a', 'b'] }, 'groups')).toEqual(['a', 'b']); + }); + it('splits a space/comma-delimited string claim', () => { + expect(extractGroups({ roles: 'admin, netops ops' }, 'roles')).toEqual(['admin', 'netops', 'ops']); + }); + it('returns [] when the claim is missing', () => { + expect(extractGroups({}, 'groups')).toEqual([]); + }); +}); + +describe('mapGroupsToRole', () => { + const map: Record = { admins: 'admin', ops: 'operator', staff: 'viewer' }; + it('picks the highest-privilege matching role', () => { + expect(mapGroupsToRole(['staff', 'ops', 'admins'], map)).toBe('admin'); + expect(mapGroupsToRole(['staff', 'ops'], map)).toBe('operator'); + }); + it('returns null when no group matches', () => { + expect(mapGroupsToRole(['unknown'], map)).toBeNull(); + expect(mapGroupsToRole([], map)).toBeNull(); + }); +}); + +describe('deriveUsername', () => { + it('prefers the configured username claim', () => { + expect(deriveUsername({ preferred_username: 'jdoe', email: 'j@x.io', sub: 'abc' }, cfg())).toBe('jdoe'); + }); + it('falls back to the email local-part', () => { + expect(deriveUsername({ email: 'jane.doe@x.io', sub: 'abc' }, cfg())).toBe('jane.doe'); + }); + it('falls back to sub', () => { + expect(deriveUsername({ sub: 'sub-123' }, cfg())).toBe('sub-123'); + }); + it('clamps to 50 chars', () => { + expect(deriveUsername({ preferred_username: 'x'.repeat(80) }, cfg()).length).toBe(50); + }); +}); + +describe('getEmail / isEmailVerified', () => { + it('extracts a valid email', () => { + expect(getEmail({ email: 'a@b.com' }, cfg())).toBe('a@b.com'); + expect(getEmail({ email: 'not-an-email' }, cfg())).toBeNull(); + expect(getEmail({}, cfg())).toBeNull(); + }); + it('reads email_verified as boolean or string', () => { + expect(isEmailVerified({ email_verified: true })).toBe(true); + expect(isEmailVerified({ email_verified: 'true' })).toBe(true); + expect(isEmailVerified({ email_verified: false })).toBe(false); + expect(isEmailVerified({})).toBe(false); + }); +}); + +describe('emailDomainAllowed', () => { + it('allows any domain when the allowlist is empty', () => { + expect(emailDomainAllowed('a@anywhere.io', [])).toBe(true); + }); + it('enforces the allowlist (case-insensitive, tolerates leading @)', () => { + expect(emailDomainAllowed('a@corp.com', ['corp.com'])).toBe(true); + expect(emailDomainAllowed('a@CORP.com', ['@corp.com'])).toBe(true); + expect(emailDomainAllowed('a@evil.com', ['corp.com'])).toBe(false); + expect(emailDomainAllowed(null, ['corp.com'])).toBe(false); + }); +}); diff --git a/backend/src/services/oidc/__tests__/oidcConfig.test.ts b/backend/src/services/oidc/__tests__/oidcConfig.test.ts new file mode 100644 index 0000000..cb4c33d --- /dev/null +++ b/backend/src/services/oidc/__tests__/oidcConfig.test.ts @@ -0,0 +1,25 @@ +import { encrypt } from '../../../utils/crypto'; +import { getClientSecret, maskedConfig, DEFAULT_OIDC_CONFIG, type OidcConfig } from '../oidcConfig'; + +describe('OIDC config secret handling', () => { + it('decrypts a stored client secret', () => { + const config: OidcConfig = { ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: encrypt('super-secret') }; + expect(getClientSecret(config)).toBe('super-secret'); + }); + + it('returns null when no secret is stored', () => { + expect(getClientSecret({ ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: null })).toBeNull(); + }); + + it('masks the secret and never exposes ciphertext', () => { + const config: OidcConfig = { ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: encrypt('x') }; + const masked = maskedConfig(config); + expect(masked.has_secret).toBe(true); + expect('client_secret_encrypted' in masked).toBe(false); + expect(JSON.stringify(masked)).not.toContain('client_secret_encrypted'); + }); + + it('reports has_secret=false when unset', () => { + expect(maskedConfig({ ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: null }).has_secret).toBe(false); + }); +}); diff --git a/backend/src/services/oidc/claimMapping.ts b/backend/src/services/oidc/claimMapping.ts new file mode 100644 index 0000000..43e58d5 --- /dev/null +++ b/backend/src/services/oidc/claimMapping.ts @@ -0,0 +1,61 @@ +/** + * Pure helpers that map OIDC ID-token / userinfo claims onto the platform's user + * fields. Kept free of DB/network so they can be unit-tested in isolation. + */ +import type { AppRole, OidcConfig } from './oidcConfig'; + +const ROLE_RANK: Record = { viewer: 0, operator: 1, admin: 2 }; + +type Claims = Record; + +/** Extract the groups claim as a string[] (handles array or space/comma string). */ +export function extractGroups(claims: Claims, groupsClaim: string): string[] { + const raw = claims[groupsClaim]; + if (Array.isArray(raw)) return raw.map((g) => String(g)); + if (typeof raw === 'string') return raw.split(/[\s,]+/).filter(Boolean); + return []; +} + +/** + * Highest-privilege role among the user's groups, or null if none of their + * groups are mapped (caller decides the fallback). + */ +export function mapGroupsToRole(groups: string[], map: Record): AppRole | null { + let best: AppRole | null = null; + for (const g of groups) { + const r = map[g]; + if (r && (best === null || ROLE_RANK[r] > ROLE_RANK[best])) best = r; + } + return best; +} + +/** Derive a base username from the configured claim, falling back to email local-part then sub. */ +export function deriveUsername(claims: Claims, config: OidcConfig): string { + const fromClaim = claims[config.username_claim]; + if (typeof fromClaim === 'string' && fromClaim.trim()) return clamp(fromClaim.trim()); + const email = claims[config.email_claim]; + if (typeof email === 'string' && email.includes('@')) return clamp(email.split('@')[0]); + return clamp(String(claims.sub ?? 'user')); +} + +/** username column is VARCHAR(50). */ +function clamp(s: string): string { + return s.slice(0, 50); +} + +export function getEmail(claims: Claims, config: OidcConfig): string | null { + const v = claims[config.email_claim] ?? claims.email; + return typeof v === 'string' && v.includes('@') ? v : null; +} + +export function isEmailVerified(claims: Claims): boolean { + return claims.email_verified === true || claims.email_verified === 'true'; +} + +/** True if no allowlist is configured, or the email's domain is on it. */ +export function emailDomainAllowed(email: string | null, allowed: string[]): boolean { + if (!allowed || allowed.length === 0) return true; + if (!email || !email.includes('@')) return false; + const domain = email.split('@')[1].toLowerCase(); + return allowed.map((d) => d.toLowerCase().replace(/^@/, '')).includes(domain); +} diff --git a/backend/src/services/oidc/oidcConfig.ts b/backend/src/services/oidc/oidcConfig.ts new file mode 100644 index 0000000..bea5983 --- /dev/null +++ b/backend/src/services/oidc/oidcConfig.ts @@ -0,0 +1,98 @@ +/** + * OIDC provider configuration, persisted as a single JSONB row in app_settings + * under OIDC_SETTINGS_KEY. The client secret is encrypted at rest with the + * platform's self-healing key (utils/crypto). This key is deliberately excluded + * from the generic GET/PUT /api/settings surface (see routes/settings.ts) so the + * secret is only ever written through the dedicated, admin-only OIDC endpoints. + */ +import { query, queryOne } from '../../config/database'; +import { encrypt, decrypt } from '../../utils/crypto'; + +export const OIDC_SETTINGS_KEY = 'oidc_config'; + +export type AppRole = 'admin' | 'operator' | 'viewer'; +export const APP_ROLES: AppRole[] = ['admin', 'operator', 'viewer']; + +export interface OidcConfig { + enabled: boolean; + issuer_url: string; + client_id: string; + /** AES-GCM ciphertext of the client secret, or null if none set. */ + client_secret_encrypted: string | null; + scopes: string; + username_claim: string; + email_claim: string; + groups_claim: string; + group_role_map: Record; + default_role: AppRole; + auto_provision: boolean; + link_by_verified_email: boolean; + allowed_email_domains: string[]; + button_label: string; + /** Optional override for building the redirect URI (else derived from the request). */ + public_base_url: string; +} + +export const DEFAULT_OIDC_CONFIG: OidcConfig = { + enabled: false, + issuer_url: '', + client_id: '', + client_secret_encrypted: null, + scopes: 'openid profile email', + username_claim: 'preferred_username', + email_claim: 'email', + groups_claim: 'groups', + group_role_map: {}, + default_role: 'viewer', + auto_provision: true, + link_by_verified_email: true, + allowed_email_domains: [], + button_label: 'Sign in with SSO', + public_base_url: '', +}; + +export async function loadOidcConfig(): Promise { + const row = await queryOne<{ value: Partial }>( + `SELECT value FROM app_settings WHERE key = $1`, + [OIDC_SETTINGS_KEY] + ); + return { ...DEFAULT_OIDC_CONFIG, ...(row?.value ?? {}) }; +} + +/** + * Persist config. `newSecret` (plaintext) is encrypted and stored; pass undefined + * to keep the existing secret, or '' to clear it. + */ +export async function saveOidcConfig( + patch: Partial, + newSecret?: string +): Promise { + const current = await loadOidcConfig(); + const merged: OidcConfig = { ...current, ...patch }; + + if (newSecret !== undefined) { + merged.client_secret_encrypted = newSecret ? encrypt(newSecret) : null; + } + + await query( + `INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [OIDC_SETTINGS_KEY, JSON.stringify(merged)] + ); + return merged; +} + +export function getClientSecret(config: OidcConfig): string | null { + if (!config.client_secret_encrypted) return null; + try { + return decrypt(config.client_secret_encrypted); + } catch { + return null; + } +} + +/** Config shape safe to return to an admin UI — no secret material. */ +export function maskedConfig(config: OidcConfig): Omit & { has_secret: boolean } { + const { client_secret_encrypted, ...rest } = config; + return { ...rest, has_secret: !!client_secret_encrypted }; +} diff --git a/frontend/package.json b/frontend/package.json index dc8f209..59a251d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.12-beta", + "version": "0.17.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4856aa..ba4aed2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import TopologyPage from './pages/TopologyPage'; import BackupsPage from './pages/BackupsPage'; import FirmwarePage from './pages/FirmwarePage'; import SettingsPage from './pages/SettingsPage'; +import OidcCallbackPage from './pages/OidcCallbackPage'; import WirelessPage from './pages/WirelessPage'; import WirelessSettingsPage from './pages/WirelessSettingsPage'; import GuestWifiPage from './pages/GuestWifiPage'; @@ -37,6 +38,7 @@ export default function App() { } /> + } /> oidcApi.getConfig().then((r) => r.data), + enabled: isAdmin, + }); + + if (!isAdmin) { + return
Single sign-on can only be configured by administrators.
; + } + if (!data) return
Loading…
; + + // Remount when the loaded config identity changes so form state re-initializes. + return ; +} + +function OidcForm({ initial }: { initial: OidcConfigView }) { + const qc = useQueryClient(); + const [form, setForm] = useState(initial); + const [secret, setSecret] = useState(''); + const [groupRows, setGroupRows] = useState( + Object.entries(initial.group_role_map || {}).map(([group, role]) => ({ group, role })) + ); + const [domains, setDomains] = useState((initial.allowed_email_domains || []).join(', ')); + const [copied, setCopied] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null); + const [saved, setSaved] = useState(false); + + const save = useMutation({ + mutationFn: () => { + const group_role_map: Record = {}; + for (const r of groupRows) if (r.group.trim()) group_role_map[r.group.trim()] = r.role; + const allowed_email_domains = domains.split(',').map((d) => d.trim()).filter(Boolean); + const payload: Partial & { client_secret?: string } = { + ...form!, + group_role_map, + allowed_email_domains, + }; + delete (payload as Record).redirect_uri; + delete (payload as Record).has_secret; + if (secret) payload.client_secret = secret; + return oidcApi.updateConfig(payload); + }, + onSuccess: (r) => { + setSecret(''); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + qc.setQueryData(['oidc-config'], r.data); + }, + }); + + const test = useMutation({ + mutationFn: () => oidcApi.test(form.issuer_url), + onSuccess: (r) => setTestResult({ ok: r.data.ok, msg: r.data.ok ? `Discovered ${r.data.issuer}` : (r.data.error || 'Failed') }), + onError: (e: unknown) => setTestResult({ ok: false, msg: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Discovery failed' }), + }); + + const set = (k: K, v: OidcConfigView[K]) => setForm({ ...form, [k]: v }); + + const copyRedirect = () => { + navigator.clipboard.writeText(form.redirect_uri).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); + }; + + return ( +
+
+ +

Single Sign-On (OIDC)

+ authenticate against your identity provider + +
+ +
+ {/* Redirect URI to register with the IdP */} +
+ +
+ {form.redirect_uri} + +
+
+ + {/* Provider connection */} +
+
+ + set('issuer_url', e.target.value)} /> +
+
+ + set('client_id', e.target.value)} /> +
+
+ + setSecret(e.target.value)} /> +
+
+ + set('scopes', e.target.value)} /> +
+
+ + set('button_label', e.target.value)} /> +
+
+ +
+ + {testResult && ( + + {testResult.ok ? : }{testResult.msg} + + )} +
+ + {/* Claim mapping */} +
+
+ + set('username_claim', e.target.value)} /> +
+
+ + set('email_claim', e.target.value)} /> +
+
+ + set('groups_claim', e.target.value)} /> +
+
+ + {/* Group → role mapping */} +
+ +
+ {groupRows.map((row, i) => ( +
+ setGroupRows(groupRows.map((r, j) => j === i ? { ...r, group: e.target.value } : r))} /> + → + + +
+ ))} + +
+
+ + +
+
+ + {/* Provisioning */} +
+ + +
+ + setDomains(e.target.value)} /> +
+
+ + set('public_base_url', e.target.value)} /> +
+
+ +
+ + {saved && Saved} + {save.isError && {(save.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Save failed'}} +
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 041c8c7..2f42820 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,6 @@ -import { useState, FormEvent, useRef } from 'react'; +import { useState, FormEvent, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Network, Eye, EyeOff, AlertCircle, ShieldCheck } from 'lucide-react'; +import { Network, Eye, EyeOff, AlertCircle, ShieldCheck, LogIn } from 'lucide-react'; import { authApi } from '../services/api'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; @@ -16,10 +16,22 @@ export default function LoginPage() { const [totpCode, setTotpCode] = useState(''); const totpInputRef = useRef(null); + const [sso, setSso] = useState<{ enabled: boolean; button_label: string }>({ enabled: false, button_label: 'Sign in with SSO' }); + const setAuth = useAuthStore((s) => s.setAuth); const navigate = useNavigate(); const { theme, toggleTheme } = useThemeStore(); + useEffect(() => { + authApi.oidcStatus().then((r) => setSso(r.data)).catch(() => {}); + // Surface an SSO error passed back on the redirect (?error=sso&reason=...). + const params = new URLSearchParams(window.location.search); + if (params.get('error') === 'sso') { + setError(params.get('reason') || 'Single sign-on failed. Please try again.'); + window.history.replaceState({}, '', window.location.pathname); + } + }, []); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -234,6 +246,28 @@ export default function LoginPage() { totpToken ? 'Verify Code' : 'Sign in' )} + + {sso.enabled && !totpToken && ( + <> +
+
+ or +
+
+ + + )}
diff --git a/frontend/src/pages/OidcCallbackPage.tsx b/frontend/src/pages/OidcCallbackPage.tsx new file mode 100644 index 0000000..46fdfe8 --- /dev/null +++ b/frontend/src/pages/OidcCallbackPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authApi } from '../services/api'; +import { useAuthStore } from '../store/authStore'; +import type { User, UserRole } from '../types'; + +/** + * Landing route for the OIDC redirect. The backend hands us the session JWT in + * the URL fragment (never sent to the server); we hydrate the auth store and go + * to the dashboard. + */ +export default function OidcCallbackPage() { + const setAuth = useAuthStore((s) => s.setAuth); + const navigate = useNavigate(); + const ran = useRef(false); + + useEffect(() => { + if (ran.current) return; + ran.current = true; + + const hash = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const token = hash.get('token'); + // Same-site relative paths only (reject absolute / protocol-relative). + const rawReturn = hash.get('returnTo') || '/dashboard'; + const returnTo = /^\/(?!\/)[^\\]*$/.test(rawReturn) ? rawReturn : '/dashboard'; + + if (!token) { + navigate('/login?error=sso&reason=' + encodeURIComponent('No token returned'), { replace: true }); + return; + } + + // Persist the token first so the /me request is authenticated. + setAuth(token, { id: 0, username: '', role: 'viewer' }); + authApi.me() + .then((r) => { + const u = (r.data as { user: { userId?: number; id?: number; username: string; role: UserRole } }).user; + const user: User = { id: u.id ?? u.userId ?? 0, username: u.username, role: u.role }; + setAuth(token, user); + navigate(returnTo.startsWith('/') ? returnTo : '/dashboard', { replace: true }); + }) + .catch(() => { + useAuthStore.getState().logout(); + navigate('/login?error=sso&reason=' + encodeURIComponent('Could not load your account'), { replace: true }); + }); + }, [navigate, setAuth]); + + return ( +
+
+ + Signing you in… +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index cbc11e1..c76034d 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X, - ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, + ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn, } from 'lucide-react'; import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api'; import type { MaintenanceWindow, ConfigTemplate } from '../services/api'; @@ -17,6 +17,7 @@ import { import clsx from 'clsx'; import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings'; import AutomationSettings from '../components/settings/AutomationSettings'; +import OidcSettings from '../components/settings/OidcSettings'; const ROLE_META: Record = { admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' }, @@ -48,7 +49,7 @@ export default function SettingsPage() { const { theme, setTheme } = useThemeStore(); const isAdmin = user?.role === 'admin'; const canWrite = user?.role !== 'viewer'; - const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general'); const [auditSearch, setAuditSearch] = useState(''); const [auditPage, setAuditPage] = useState(1); const [newTagName, setNewTagName] = useState(''); @@ -472,6 +473,7 @@ export default function SettingsPage() { tabs: [ { key: 'general' as const, label: 'General', icon: Settings }, { key: 'users' as const, label: 'Users & Roles', icon: Users }, + ...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []), { key: 'certificate' as const, label: 'Certificate', icon: Lock }, ...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []), ], @@ -872,9 +874,17 @@ export default function SettingsPage() { {(users as User[]).map((u) => ( - {u.username} - {u.id === user?.id && ( - (you) +
+ {u.username} + {u.id === user?.id && ( + (you) + )} + {u.auth_provider === 'oidc' && ( + SSO + )} +
+ {u.email && ( +
{u.email}
)} @@ -1806,6 +1816,9 @@ export default function SettingsPage() { {activeTab === 'automation' && ( )} + {activeTab === 'sso' && ( + + )} {/* ── Config Templates ── */} {activeTab === 'templates' && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5e7998b..cfb1499 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -21,6 +21,7 @@ import type { ConfigSnapshotFull, ConfigDiffResponse, ConfigCaptureResult, + UserRole, } from '../types'; const api = axios.create({ @@ -66,6 +67,38 @@ export const authApi = { api.put('/auth/password', { currentPassword, newPassword }), securityStatus: () => api.get<{ warnings: string[] }>('/auth/security-status'), + oidcStatus: () => + api.get<{ enabled: boolean; button_label: string }>('/auth/oidc/status'), +}; + +export interface OidcConfigView { + enabled: boolean; + issuer_url: string; + client_id: string; + has_secret: boolean; + scopes: string; + username_claim: string; + email_claim: string; + groups_claim: string; + group_role_map: Record; + default_role: UserRole; + auto_provision: boolean; + link_by_verified_email: boolean; + allowed_email_domains: string[]; + button_label: string; + public_base_url: string; + redirect_uri: string; +} + +export const oidcApi = { + getConfig: () => api.get('/auth/oidc/config'), + updateConfig: (data: Partial & { client_secret?: string }) => + api.put('/auth/oidc/config', data), + test: (issuer_url?: string) => + api.post<{ ok: boolean; issuer?: string; claims_supported?: string[]; error?: string }>( + '/auth/oidc/test', + { issuer_url } + ), }; // ─── Devices ────────────────────────────────────────────────────────────────── diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index db12144..97f0e30 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -3,7 +3,9 @@ export type UserRole = 'admin' | 'operator' | 'viewer'; export interface User { id: number; username: string; + email?: string | null; role: UserRole; + auth_provider?: 'local' | 'oidc'; created_at?: string; } diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 90cc5d8..6a20b56 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.16.12 Beta'; +export const APP_VERSION = 'v0.17.0 Beta';