feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)

Add OpenID Connect single sign-on so the platform can authenticate against
any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak,
Authentik, …) alongside local accounts. Resolves the feature request in #73.

Flow (backend-driven Authorization Code + PKCE):
- GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce
  transaction in Redis (single-use, 10m TTL); the callback validates the ID
  token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the
  identity to a local user, and mints the platform's normal session JWT —
  handed to the SPA via URL fragment. No sessions/cookies introduced.

Identity mapping (users table gains email/oidc_subject/oidc_issuer/
auth_provider; password_hash now nullable; partial unique indexes):
- First sign-in auto-provisions a user, or links to an existing local account
  when the IdP email is verified and matches.
- Roles derive from a configurable IdP group -> role map with a default
  fallback; existing users are never auto-demoted on a groupless login.

Configuration (admin-only, no env/redeploy needed):
- Stored as an app_settings row with the client secret encrypted at rest
  (utils/crypto); masked in responses and excluded from GET /api/settings.
- New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role
  mapping, provisioning toggles, discovery test, copyable redirect URI), a
  login-page SSO button, and an SSO badge in Users & Roles.

Security: local break-glass login preserved; PKCE + single-use state; email
linking requires email_verified; same-site-only post-login redirect.

Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping,
username derivation, config secret round-trip/masking); 114 backend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-16 18:54:36 -07:00
co-authored by Claude Opus 4.8
parent ea1ad956ff
commit c0f230bdf2
21 changed files with 1056 additions and 14 deletions
+36 -1
View File
@@ -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://<your-host>/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
+63 -2
View File
@@ -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",
+2 -1
View File
@@ -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",
+10
View File
@@ -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).
+2
View File
@@ -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);
+134
View File
@@ -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<string, string>;
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<OidcConfig> & { 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<string, unknown>).client_secret_encrypted; // never accept ciphertext from the client
delete (patch as Record<string, unknown>).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;
+9 -1
View File
@@ -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<string>([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<string, unknown> = {};
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<string, unknown>;
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);
});
+193
View File
@@ -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<Client> {
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<string> {
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<string, string>): 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<string, unknown> = { ...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<string, unknown>, config: OidcConfig): Promise<ResolvedUser> {
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<string> {
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)}`;
}
@@ -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<string, AppRole> = { 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);
});
});
@@ -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);
});
});
+61
View File
@@ -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<AppRole, number> = { viewer: 0, operator: 1, admin: 2 };
type Claims = Record<string, unknown>;
/** 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<string, AppRole>): 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);
}
+98
View File
@@ -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<string, AppRole>;
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<OidcConfig> {
const row = await queryOne<{ value: Partial<OidcConfig> }>(
`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<OidcConfig>,
newSecret?: string
): Promise<OidcConfig> {
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<OidcConfig, 'client_secret_encrypted'> & { has_secret: boolean } {
const { client_secret_encrypted, ...rest } = config;
return { ...rest, has_secret: !!client_secret_encrypted };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.16.12-beta",
"version": "0.17.0-beta",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -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() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<OidcCallbackPage />} />
<Route
path="/"
element={
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { KeyRound, Plus, Trash2, Copy, Check, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { oidcApi, type OidcConfigView } from '../../services/api';
import type { UserRole } from '../../types';
const ROLES: UserRole[] = ['viewer', 'operator', 'admin'];
type GroupRow = { group: string; role: UserRole };
export default function OidcSettings({ isAdmin }: { isAdmin: boolean }) {
const { data } = useQuery({
queryKey: ['oidc-config'],
queryFn: () => oidcApi.getConfig().then((r) => r.data),
enabled: isAdmin,
});
if (!isAdmin) {
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Single sign-on can only be configured by administrators.</div>;
}
if (!data) return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Loading…</div>;
// Remount when the loaded config identity changes so form state re-initializes.
return <OidcForm key={data.redirect_uri} initial={data} />;
}
function OidcForm({ initial }: { initial: OidcConfigView }) {
const qc = useQueryClient();
const [form, setForm] = useState<OidcConfigView>(initial);
const [secret, setSecret] = useState('');
const [groupRows, setGroupRows] = useState<GroupRow[]>(
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<string, UserRole> = {};
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<OidcConfigView> & { client_secret?: string } = {
...form!,
group_role_map,
allowed_email_domains,
};
delete (payload as Record<string, unknown>).redirect_uri;
delete (payload as Record<string, unknown>).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 extends keyof OidcConfigView>(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 (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Single Sign-On (OIDC)</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">authenticate against your identity provider</span>
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
Enabled
</label>
</div>
<div className="p-5 space-y-5">
{/* Redirect URI to register with the IdP */}
<div>
<label className="label">Redirect URI (register this with your provider)</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1.5 rounded bg-gray-50 dark:bg-slate-800 font-mono text-xs break-all text-gray-700 dark:text-slate-300">{form.redirect_uri}</code>
<button className="btn-secondary flex items-center gap-1.5" onClick={copyRedirect}>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{/* Provider connection */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Issuer URL</label>
<input className="input w-full" value={form.issuer_url} placeholder="https://login.example.com/realms/main" onChange={(e) => set('issuer_url', e.target.value)} />
</div>
<div>
<label className="label">Client ID</label>
<input className="input w-full" value={form.client_id} onChange={(e) => set('client_id', e.target.value)} />
</div>
<div>
<label className="label">Client Secret {form.has_secret && <span className="text-green-600 dark:text-green-400">(configured)</span>}</label>
<input className="input w-full" type="password" value={secret} placeholder={form.has_secret ? '•••••••• (leave blank to keep)' : 'none — public client'} onChange={(e) => setSecret(e.target.value)} />
</div>
<div>
<label className="label">Scopes</label>
<input className="input w-full" value={form.scopes} onChange={(e) => set('scopes', e.target.value)} />
</div>
<div>
<label className="label">Button label</label>
<input className="input w-full" value={form.button_label} onChange={(e) => set('button_label', e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary flex items-center gap-1.5" disabled={!form.issuer_url || test.isPending} onClick={() => test.mutate()}>
<RefreshCw className={test.isPending ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />Test discovery
</button>
{testResult && (
<span className={`flex items-center gap-1.5 text-xs ${testResult.ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{testResult.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}{testResult.msg}
</span>
)}
</div>
{/* Claim mapping */}
<div className="grid gap-3 sm:grid-cols-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<div>
<label className="label">Username claim</label>
<input className="input w-full" value={form.username_claim} onChange={(e) => set('username_claim', e.target.value)} />
</div>
<div>
<label className="label">Email claim</label>
<input className="input w-full" value={form.email_claim} onChange={(e) => set('email_claim', e.target.value)} />
</div>
<div>
<label className="label">Groups claim</label>
<input className="input w-full" value={form.groups_claim} onChange={(e) => set('groups_claim', e.target.value)} />
</div>
</div>
{/* Group → role mapping */}
<div>
<label className="label">Group → role mapping</label>
<div className="space-y-2">
{groupRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<input className="input flex-1" placeholder="IdP group name" value={row.group} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, group: e.target.value } : r))} />
<span className="text-gray-400">→</span>
<select className="input w-36" value={row.role} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, role: e.target.value as UserRole } : r))}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button className="p-1.5 text-gray-400 hover:text-red-600" onClick={() => setGroupRows(groupRows.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
))}
<button className="btn-secondary flex items-center gap-1.5" onClick={() => setGroupRows([...groupRows, { group: '', role: 'viewer' }])}>
<Plus className="w-4 h-4" />Add mapping
</button>
</div>
<div className="mt-2 flex items-center gap-2">
<label className="label mb-0">Default role (no group match)</label>
<select className="input w-36" value={form.default_role} onChange={(e) => set('default_role', e.target.value as UserRole)}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
{/* Provisioning */}
<div className="grid gap-2 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.auto_provision} onChange={(e) => set('auto_provision', e.target.checked)} />
Auto-provision new users on first sign-in
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.link_by_verified_email} onChange={(e) => set('link_by_verified_email', e.target.checked)} />
Link to existing accounts by verified email
</label>
<div>
<label className="label">Allowed email domains (comma-separated, blank = any)</label>
<input className="input w-full" value={domains} placeholder="example.com, corp.example.com" onChange={(e) => setDomains(e.target.value)} />
</div>
<div>
<label className="label">Public base URL (optional — overrides the redirect URI host)</label>
<input className="input w-full" value={form.public_base_url} placeholder="https://manager.example.com" onChange={(e) => set('public_base_url', e.target.value)} />
</div>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<button className="btn-primary flex items-center gap-1.5" disabled={save.isPending} onClick={() => save.mutate()}>
{save.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}Save
</button>
{saved && <span className="text-xs text-green-600 dark:text-green-400">Saved</span>}
{save.isError && <span className="text-xs text-red-600 dark:text-red-400">{(save.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Save failed'}</span>}
</div>
</div>
</div>
);
}
+36 -2
View File
@@ -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<HTMLInputElement>(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'
)}
</button>
{sso.enabled && !totpToken && (
<>
<div className="flex items-center gap-3 my-1">
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>or</span>
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
</div>
<button
type="button"
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
className={`w-full flex items-center justify-center gap-2 px-4 py-2 font-medium rounded-lg transition-colors border ${
isDark
? 'bg-slate-800/80 border-slate-600 text-white hover:bg-slate-700'
: 'bg-white/80 border-slate-300 text-slate-800 hover:bg-slate-50'
}`}
>
<LogIn className="w-4 h-4" />
{sso.button_label || 'Sign in with SSO'}
</button>
</>
)}
</form>
</div>
+55
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-slate-500">
<span className="inline-block w-5 h-5 border-2 border-slate-400/30 border-t-slate-400 rounded-full animate-spin" />
Signing you in…
</div>
</div>
);
}
+18 -5
View File
@@ -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<UserRole, { label: string; color: string; desc: string }> = {
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) => (
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
{u.username}
{u.id === user?.id && (
<span className="ml-2 text-xs text-blue-500">(you)</span>
<div className="flex items-center gap-2">
{u.username}
{u.id === user?.id && (
<span className="text-xs text-blue-500">(you)</span>
)}
{u.auth_provider === 'oidc' && (
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">SSO</span>
)}
</div>
{u.email && (
<div className="text-xs text-gray-400 dark:text-slate-500 font-normal">{u.email}</div>
)}
</td>
<td className="px-4 py-2.5">
@@ -1806,6 +1816,9 @@ export default function SettingsPage() {
{activeTab === 'automation' && (
<AutomationSettings isAdmin={isAdmin} />
)}
{activeTab === 'sso' && (
<OidcSettings isAdmin={isAdmin} />
)}
{/* ── Config Templates ── */}
{activeTab === 'templates' && (
<div className="space-y-4">
+33
View File
@@ -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<string, UserRole>;
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<OidcConfigView>('/auth/oidc/config'),
updateConfig: (data: Partial<OidcConfigView> & { client_secret?: string }) =>
api.put<OidcConfigView>('/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 ──────────────────────────────────────────────────────────────────
+2
View File
@@ -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;
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.12 Beta';
export const APP_VERSION = 'v0.17.0 Beta';