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:
co-authored by
Claude Opus 4.8
parent
ea1ad956ff
commit
c0f230bdf2
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -279,6 +279,7 @@ Instant search across devices, clients, and events from the top navigation bar.
|
|||||||
- Admin-only user creation and role assignment
|
- Admin-only user creation and role assignment
|
||||||
- JWT authentication with secure session handling
|
- 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
|
- **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
|
- **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
|
### 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.
|
**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
|
## Updating
|
||||||
|
|||||||
Generated
+63
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.6-beta",
|
"version": "0.16.12-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.6-beta",
|
"version": "0.16.12-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
"@influxdata/influxdb-client": "^1.33.2",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -5865,6 +5866,15 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -6517,6 +6527,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -6529,6 +6548,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -6576,6 +6604,39 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.12-beta",
|
"version": "0.17.0-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -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_secret VARCHAR(64);
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
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
|
-- Per-client daily traffic rollups from the NetFlow collector. mac_address
|
||||||
-- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other'
|
-- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other'
|
||||||
-- (clients folded by the top-N cardinality cap).
|
-- (clients folded by the top-N cardinality cap).
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { decrypt } from './utils/crypto';
|
|||||||
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
||||||
|
|
||||||
import authRoutes from './routes/auth';
|
import authRoutes from './routes/auth';
|
||||||
|
import oidcRoutes from './routes/oidc';
|
||||||
import auditLogRoutes from './routes/auditLog';
|
import auditLogRoutes from './routes/auditLog';
|
||||||
import tagsRoutes from './routes/tags';
|
import tagsRoutes from './routes/tags';
|
||||||
import maintenanceWindowsRoutes from './routes/maintenanceWindows';
|
import maintenanceWindowsRoutes from './routes/maintenanceWindows';
|
||||||
@@ -296,6 +297,7 @@ app.get('/api/health', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── Routes ───────────────────────────────────────────────────────────────────
|
// ─── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api/auth/oidc', oidcRoutes);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/devices', devicesRoutes);
|
app.use('/api/devices', devicesRoutes);
|
||||||
app.use('/api/clients', clientsRoutes);
|
app.use('/api/clients', clientsRoutes);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -4,15 +4,22 @@ import { query, queryOne } from '../config/database';
|
|||||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||||
import { validatePassword } from '../utils/passwordPolicy';
|
import { validatePassword } from '../utils/passwordPolicy';
|
||||||
|
import { OIDC_SETTINGS_KEY } from '../services/oidc/oidcConfig';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
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
|
// GET /api/settings
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
|
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
|
||||||
const map: Record<string, unknown> = {};
|
const map: Record<string, unknown> = {};
|
||||||
for (const s of settings as { key: string; value: unknown }[]) {
|
for (const s of settings as { key: string; value: unknown }[]) {
|
||||||
|
if (PROTECTED_SETTING_KEYS.has(s.key)) continue;
|
||||||
map[s.key] = s.value;
|
map[s.key] = s.value;
|
||||||
}
|
}
|
||||||
res.json(map);
|
res.json(map);
|
||||||
@@ -22,6 +29,7 @@ router.get('/', async (_req: Request, res: Response) => {
|
|||||||
router.put('/', requireAdmin, async (req: Request, res: Response) => {
|
router.put('/', requireAdmin, async (req: Request, res: Response) => {
|
||||||
const updates = req.body as Record<string, unknown>;
|
const updates = req.body as Record<string, unknown>;
|
||||||
for (const [key, value] of Object.entries(updates)) {
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (PROTECTED_SETTING_KEYS.has(key)) continue;
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = 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
|
// GET /api/settings/users
|
||||||
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
|
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
|
||||||
const users = await query(
|
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);
|
res.json(users);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.16.12-beta",
|
"version": "0.17.0-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import TopologyPage from './pages/TopologyPage';
|
|||||||
import BackupsPage from './pages/BackupsPage';
|
import BackupsPage from './pages/BackupsPage';
|
||||||
import FirmwarePage from './pages/FirmwarePage';
|
import FirmwarePage from './pages/FirmwarePage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import OidcCallbackPage from './pages/OidcCallbackPage';
|
||||||
import WirelessPage from './pages/WirelessPage';
|
import WirelessPage from './pages/WirelessPage';
|
||||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||||
import GuestWifiPage from './pages/GuestWifiPage';
|
import GuestWifiPage from './pages/GuestWifiPage';
|
||||||
@@ -37,6 +38,7 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/auth/callback" element={<OidcCallbackPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, FormEvent, useRef } from 'react';
|
import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { authApi } from '../services/api';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
@@ -16,10 +16,22 @@ export default function LoginPage() {
|
|||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const totpInputRef = useRef<HTMLInputElement>(null);
|
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 setAuth = useAuthStore((s) => s.setAuth);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { theme, toggleTheme } = useThemeStore();
|
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) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -234,6 +246,28 @@ export default function LoginPage() {
|
|||||||
totpToken ? 'Verify Code' : 'Sign in'
|
totpToken ? 'Verify Code' : 'Sign in'
|
||||||
)}
|
)}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
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';
|
} from 'lucide-react';
|
||||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
||||||
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
||||||
import AutomationSettings from '../components/settings/AutomationSettings';
|
import AutomationSettings from '../components/settings/AutomationSettings';
|
||||||
|
import OidcSettings from '../components/settings/OidcSettings';
|
||||||
|
|
||||||
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
|
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' },
|
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 { theme, setTheme } = useThemeStore();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const canWrite = user?.role !== 'viewer';
|
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 [auditSearch, setAuditSearch] = useState('');
|
||||||
const [auditPage, setAuditPage] = useState(1);
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
const [newTagName, setNewTagName] = useState('');
|
const [newTagName, setNewTagName] = useState('');
|
||||||
@@ -472,6 +473,7 @@ export default function SettingsPage() {
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
{ 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 },
|
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||||
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||||
],
|
],
|
||||||
@@ -872,9 +874,17 @@ export default function SettingsPage() {
|
|||||||
{(users as User[]).map((u) => (
|
{(users as User[]).map((u) => (
|
||||||
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
<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">
|
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||||
{u.username}
|
<div className="flex items-center gap-2">
|
||||||
{u.id === user?.id && (
|
{u.username}
|
||||||
<span className="ml-2 text-xs text-blue-500">(you)</span>
|
{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>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
@@ -1806,6 +1816,9 @@ export default function SettingsPage() {
|
|||||||
{activeTab === 'automation' && (
|
{activeTab === 'automation' && (
|
||||||
<AutomationSettings isAdmin={isAdmin} />
|
<AutomationSettings isAdmin={isAdmin} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'sso' && (
|
||||||
|
<OidcSettings isAdmin={isAdmin} />
|
||||||
|
)}
|
||||||
{/* ── Config Templates ── */}
|
{/* ── Config Templates ── */}
|
||||||
{activeTab === 'templates' && (
|
{activeTab === 'templates' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
ConfigSnapshotFull,
|
ConfigSnapshotFull,
|
||||||
ConfigDiffResponse,
|
ConfigDiffResponse,
|
||||||
ConfigCaptureResult,
|
ConfigCaptureResult,
|
||||||
|
UserRole,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@@ -66,6 +67,38 @@ export const authApi = {
|
|||||||
api.put('/auth/password', { currentPassword, newPassword }),
|
api.put('/auth/password', { currentPassword, newPassword }),
|
||||||
securityStatus: () =>
|
securityStatus: () =>
|
||||||
api.get<{ warnings: string[] }>('/auth/security-status'),
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ export type UserRole = 'admin' | 'operator' | 'viewer';
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
auth_provider?: 'local' | 'oidc';
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.16.12 Beta';
|
export const APP_VERSION = 'v0.17.0 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user