From f22bb54e257c43efba57fb2eb69b61928a14b23b Mon Sep 17 00:00:00 2001
From: nogueiraguh
Date: Thu, 2 Apr 2026 10:26:00 -0300
Subject: [PATCH] feat: Oracle IAM OIDC, force password change, security
hardening
- OIDC: authorization code flow, JWKS validation, JIT provisioning, group-to-role mapping, dual auth (local/oidc/both)
- Force password change: random admin password on first install, modal blocks UI until changed
- APP_SECRET required in docker-compose (fail if missing), .env.example improved
- Login page: Oracle IAM button (conditional), hide form in oidc-only mode
- OracleIamPage: test connection via backend, masked client_secret handling
- UsersPage: OIDC badge, auth_provider in list
- i18n: login.oidcButton, login.or (pt/en/es)
- README v3.1: Terminal, User Management, Security, OIDC endpoints, versioning
---
.env.example | 16 +-
README.md | 67 ++++-
backend/app.py | 253 +++++++++++++++++-
docker-compose.yml | 2 +-
frontend-react/src/App.tsx | 68 ++++-
frontend-react/src/api/endpoints/auth.ts | 5 +
frontend-react/src/i18n/en.ts | 2 +
frontend-react/src/i18n/es.ts | 2 +
frontend-react/src/i18n/pt.ts | 2 +
frontend-react/src/pages/LoginPage.tsx | 157 ++++++-----
.../src/pages/admin/OracleIamPage.tsx | 17 +-
frontend-react/src/pages/admin/UsersPage.tsx | 5 +
frontend-react/src/stores/auth.ts | 24 +-
13 files changed, 514 insertions(+), 106 deletions(-)
diff --git a/.env.example b/.env.example
index 73460bb..9e52d92 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,19 @@
-# Generate with: openssl rand -hex 32
-APP_SECRET=change-me-generate-a-secure-random-string
+# REQUIRED — Generate with: openssl rand -hex 64
+# This key encrypts JWT tokens, Fernet credentials, and session data.
+# It MUST be the same across container restarts and all workers.
+APP_SECRET=
+
+# JWT token expiry in hours (default: 12)
JWT_EXPIRY_HOURS=12
+
+# Frontend port (default: 8080)
PORT=8080
+
+# CORS allowed origins (comma-separated, e.g., https://your-domain.com)
CORS_ORIGINS=http://localhost:8080
+
+# Timezone
TZ=America/Sao_Paulo
+
+# Suppress OCI CLI file permission warnings
OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING=True
diff --git a/README.md b/README.md
index c9ff818..03a8e4d 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -23,7 +23,7 @@
AI Agent — Infrastructure & Security Engineer is a self-hosted web application that automates **CIS Oracle Cloud Infrastructure Foundations Benchmark 3.0** compliance checks, powered by **OCI Generative AI** for intelligent analysis and an **MCP (Model Context Protocol)** server architecture for extensible task execution.
-The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **React 19 SPA** (TypeScript, Vite), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en), and **Recharts** visualizations.
+The platform combines security compliance scanning, AI-powered chat with **RAG (Retrieval-Augmented Generation)**, infrastructure exploration, and vector-based knowledge storage into a single, containerized solution with a **React 19 SPA** (TypeScript, Vite), **Oracle Dark Premium** theme (light/dark modes), **KPI dashboard** with compliance gauge, **i18n** (pt/en/es), and **Recharts** visualizations.
---
@@ -219,13 +219,37 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **KPI Dashboard**: compliance score gauge (SVG semicircular arc with gradient), pass/fail/total KPI cards, donut chart, horizontal bar chart — powered by Recharts
- **Animated UI**: staggered fade-in, smooth theme transitions, hover effects, shimmer loading states, pulse alerts
+### OCI CLI Terminal
+- **Linux-style web terminal** for OCI CLI interaction directly from the browser
+- **Config selector**: choose the OCI Config to run commands against
+- **Tab autocomplete**: auto-completes OCI CLI commands and subcommands
+- **OCID auto-lookup**: paste any OCID (60+ resource types) → auto-detects type and runs `oci get`
+- **`find` by name/IP**: search OCI resources by display name or IP address via OCI Search API
+- **Command history**: per-user, navigable with arrow keys
+- **Help panel**: collapsible help with all commands and keyboard shortcuts
+- **State persistence**: terminal output, config selection, and history survive page navigation (Zustand store)
+- **User isolation**: commands execute with user's own OCI config only, history is per-user
+
+### User Management
+- **Sub-menu**: Users, My Settings, Oracle IAM
+- **My Settings**: per-user timezone, language preference (pt/en/es), password change (local users only), MFA toggle
+- **Oracle IAM page**: OIDC configuration UI (issuer, client ID/secret, redirect URI, group-to-role mapping, test connection)
+- **Users list**: admin view with role, status, MFA, auth provider badge (OIDC/Local)
+
### Security
- **JWT authentication** with configurable expiry
- **TOTP MFA** (Google Authenticator / Authy compatible)
+- **Oracle IAM OIDC**: SSO via Oracle Identity Domains — authorization code flow with PKCE, JWKS signature validation, JIT user provisioning, group-to-role mapping
+- **Dual auth modes**: local only, OIDC only, or both simultaneously
- **RBAC** with 3 roles: Admin, User, Viewer
-- Audit logging for all operations
-- Encrypted credential storage
-- **Per-user timezone** setting (not global)
+- **Fernet encryption** (AES-128-CBC + HMAC-SHA256) for credentials and sensitive settings
+- **User isolation**: ownership checks on ~70 endpoints, `is_global` flag for shared resources, private reports, per-user embeddings
+- **Export sanitization**: config export strips private keys, passwords, and OCIDs
+- **Sensitive settings protection**: `oidc_*`, `secret_*` keys blocked for non-admin users
+- Audit logging for all operations (auth, OIDC, JIT provisioning, resource actions)
+- Rate limiting on login and OIDC endpoints (10 attempts / 5 min)
+- **Per-user timezone and language** settings
+- Non-root container execution (`runuser`)
---
@@ -493,19 +517,19 @@ Allow group to read buckets in compartment
```
oci-cis-agent/
├── backend/
-│ ├── app.py # FastAPI application (~9800 lines)
+│ ├── app.py # FastAPI application (~10500 lines)
│ ├── cis_reports.py # Oracle CIS Benchmark checker (6660 lines, report engine)
│ ├── mcp_cis_server.py # MCP server with 12 granular CIS tools (~700 lines)
│ ├── gen_tf_reference.py # OCI Terraform provider resource catalog generator
│ ├── Dockerfile # Python 3.12 + OCI CLI + Terraform 1.14.7 + Chromium
│ └── requirements.txt # Dependencies
├── frontend-react/
-│ ├── src/ # React 19 SPA (TypeScript, 39 source files, ~15800 lines)
-│ │ ├── pages/ # 16 page components (Chat, Terraform, Explorer, OCI Services, Reports, Config, Admin)
+│ ├── src/ # React 19 SPA (TypeScript, 46 source files, ~19500 lines)
+│ │ ├── pages/ # 20 page components (Chat, Terraform, Explorer, Terminal, OCI Services, Reports, Config, Admin)
│ │ ├── api/ # API client + endpoint modules
-│ │ ├── stores/ # Zustand stores (app state persistence across navigation)
+│ │ ├── stores/ # Zustand stores (app, auth, terminal — state persistence across navigation)
│ │ ├── hooks/ # Custom hooks (theme, polling)
-│ │ └── i18n/ # Internationalization (pt/en, 703 keys)
+│ │ └── i18n/ # Internationalization (pt/en/es, 850+ keys)
│ └── dist/ # Built SPA served at /
├── nginx/
│ └── default.conf # Reverse proxy (React SPA + API /api/)
@@ -528,6 +552,11 @@ oci-cis-agent/
| POST | `/api/auth/logout` | Logout and invalidate session |
| POST | `/api/auth/register` | Create user (admin only) |
| POST | `/api/auth/change-password` | Change password |
+| GET | `/api/auth/oidc/config` | Public auth mode (local/oidc/both) |
+| GET | `/api/auth/oidc/login` | Redirect to Oracle Identity Domains |
+| GET | `/api/auth/oidc/callback` | OIDC callback (code exchange + JIT provisioning) |
+| POST | `/api/auth/oidc/logout` | OIDC logout + IdP logout URL |
+| POST | `/api/settings/oidc/test` | Test OIDC discovery (admin) |
### OCI Management
@@ -706,10 +735,23 @@ oci-cis-agent/
| Method | Endpoint | Description |
|--------|----------|-------------|
+| GET | `/api/users/me` | Get current user profile (includes auth_provider) |
| GET | `/api/users/me/timezone` | Get user timezone |
| PUT | `/api/users/me/timezone` | Set user timezone |
+| GET | `/api/users/me/language` | Get user language preference |
+| PUT | `/api/users/me/language` | Set user language preference (pt/en/es) |
| GET | `/api/settings/timezone/options` | List available timezone options |
+### OCI CLI Terminal
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/terminal/execute` | Execute OCI CLI command |
+| GET | `/api/terminal/autocomplete` | Tab autocomplete suggestions |
+| POST | `/api/terminal/ocid-lookup` | OCID auto-lookup (60+ types) |
+| POST | `/api/terminal/find` | Search resources by name/IP |
+| GET | `/api/terminal/history` | Get command history (per-user) |
+
### CIS Engine
| Method | Endpoint | Description |
@@ -783,8 +825,8 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Component | Technology |
|-----------|-----------|
| Backend | Python 3.12, FastAPI 0.115, Uvicorn |
-| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, i18n (pt/en), rehype-highlight |
-| Auth | JWT + TOTP MFA + RBAC |
+| Frontend | React 19 SPA (Vite + TypeScript), Oracle Dark Premium theme, Recharts, Zustand, i18n (pt/en/es), rehype-highlight |
+| Auth | JWT + TOTP MFA + RBAC + Oracle IAM OIDC |
| Database | SQLite (WAL mode) |
| OCI SDK | `oci` 2.133.0, `oci-cli` |
| ADB | `python-oracledb` 2.4.1 (Thin mode) |
@@ -801,6 +843,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
+| **v3.1** | 2026-04 | **Oracle IAM OIDC**: SSO via Oracle Identity Domains — authorization code flow, JWKS ID token validation (RS256), CSRF protection (state+nonce), JIT user provisioning from OIDC claims, group-to-role mapping (admin/user/viewer), dual auth modes (local/oidc/both), `oidc_client_secret` encrypted with Fernet, rate-limited OIDC endpoints, IdP logout support. **OCI CLI Terminal**: Linux-style web terminal with Tab autocomplete, OCID auto-lookup (60+ resource types), `find` by name/IP via OCI Search API, per-user command history, help panel. **User Management**: sub-menu (Users, My Settings, Oracle IAM), per-user timezone/language/MFA/password change, auth_provider badge (OIDC/Local) in user list. **Spanish i18n**: 3 languages (pt/en/es), 850+ i18n keys. **Security hardening**: Fernet encryption (AES) for credentials (replacing base64), export sanitization (strips private keys/passwords/OCIDs), sensitive settings protection (`oidc_*`/`secret_*` blocked for non-admin). **User isolation**: ownership checks on ~70 endpoints, `is_global` flag for shared ADB/MCP configs, private reports, per-user embeddings with `user_id` metadata. **State persistence**: Terminal and Explorer state survives page navigation via Zustand stores (compartment tree, selected regions, counts, terminal output, history). **175+ API endpoints**, **20 pages**, **~36,500 lines of code**. |
| **v3.0** | 2026-03 | **OCI Services page**: new menu item with Service Status tab (auto-detect 6 security services — VSS, Data Safe, Cloud Guard, Bastion, Security Zones, Vault — per tenancy via OCI API + user overrides) and OCI Health tab (real-time Oracle service health from ocistatus.oraclecloud.com, 49 regions, search, geo filters). **Compliance Report v3.0**: OCI Services section with summary table + detailed descriptions + Cloud Guard recommendations, back page (Connect with us + copyright), DOCX download (Pillow camouflage strip, green header tables, result boxes, code blocks, A4 format), compliance_data.json shared between HTML and DOCX, progress bar during RAG generation (X/35, percentage), ADB connection pre-check (fail fast 503). **CIS PDF Chunker**: segments CIS PDF by recommendation number (X.X Ensure...), target 7000 chars with 500-char overlap, filters TOC/appendix/page headers — 54/54 recommendations with complete Description + Rationale + Remediation. **Embedding improvements**: auto-detect dimension from DDL (even on empty tables), auto-detect model per dimension (1536=small, 3072=large), RAG timeout 60s + retry (was 30s), direct SQL fetch by metadata recommendationNumber for overlap chunks. **Per-user timezone** (not global). **New API endpoints**: OCI Health proxy, OCI Services status/overrides, user timezone CRUD, DOCX download, compliance progress details, timezone options. |
| **v2.8** | 2026-03 | **Legacy frontend removed**: React SPA now served at root `/` (no more `/app/` prefix), legacy vanilla JS SPA removed. **Compliance Report ZIP download**: Chromium headless PDF generation (identical to browser print) + CSV findings bundled in ZIP. **Compliance generation state persisted server-side**: `.compliance_generating` marker file ensures generation status survives page navigation — spinner auto-resumes on return. **RAG remediation enriched**: extracts Description, Rationale, Audit, and Remediation sections from CIS vector chunks, combines up to 3 matched chunks. **Chat markdown styling**: full markdown rendering with syntax highlighting (Catppuccin Mocha theme via rehype-highlight), styled headings, lists, tables, blockquotes, inline/block code. **Default model auto-select**: Chat Agent auto-selects first GenAI config on page load. **Explorer silent polling**: no more flickering during start/stop resource actions. |
| **v2.7** | 2026-03 | **LAD A-Team CIS Compliance Report**: professional Oracle-format compliance report with cover page, TOC, Security Overview (7 pillars), CIS Assessment Summary, detailed findings with compliance % bars, RAG-powered remediation from ADB vector store (`CISRECOM` table with strict recommendation number filtering), affected resources CSV download links per non-compliant finding (JWT auth via query param). **React SPA**: 15-page React 19 frontend (TypeScript, Vite, Zustand, 38 source files). **i18n**: full internationalization with 625 keys (pt/en), language switcher persisted in localStorage. **Report management**: delete reports endpoint, embed individual report files (CSV/TXT/JSON/PDF) into vector store. **Terraform ZIP download**: replaced individual file downloads with client-side ZIP generation (pure JS, no external lib). **Explorer UX**: silent polling during start/stop actions eliminates flickering (resources update in-place without clearing the list). **Report sections collapsed by default**: HTML report and compliance report iframes start minimized. |
diff --git a/backend/app.py b/backend/app.py
index 841fd09..2f6706c 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -17,13 +17,16 @@ from fastapi import (
Query, Body, BackgroundTasks
)
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response
+from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse, Response, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
import jwt as pyjwt
# ── Config ────────────────────────────────────────────────────────────────────
-APP_SECRET = os.environ.get("APP_SECRET", secrets.token_hex(32))
+APP_SECRET = os.environ.get("APP_SECRET") or secrets.token_hex(32)
+if not os.environ.get("APP_SECRET"):
+ import warnings
+ warnings.warn("APP_SECRET not set — using random key (tokens will not survive restarts). Set APP_SECRET in .env for production.")
JWT_ALG = "HS256"
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
DATA = Path(os.environ.get("DATA_DIR", "/data"))
@@ -471,7 +474,7 @@ def init_db():
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
- for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT"]:
+ for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT", "must_change_password INTEGER DEFAULT 0"]:
try:
c.execute(f"ALTER TABLE users ADD COLUMN {col}")
except sqlite3.OperationalError:
@@ -539,11 +542,13 @@ def init_db():
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
if not adm:
+ _tmp_pw = secrets.token_urlsafe(16)
c.execute(
- "INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
- (str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
+ "INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)",
+ (str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw(_tmp_pw), "admin", 1)
)
- log.info("Default admin created: admin / admin123")
+ log.info(f"Default admin created — username: admin / password: {_tmp_pw}")
+ log.info("⚠ Change the admin password on first login!")
# ── Crypto helpers ────────────────────────────────────────────────────────────
def _hash_pw(pw): salt=secrets.token_hex(16); h=hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000); return f"{salt}:{h.hex()}"
@@ -597,6 +602,82 @@ def _safe_dec(v):
except Exception:
return v
+# ── OIDC helpers ─────────────────────────────────────────────────────────────
+_oidc_discovery_cache: dict = {} # {issuer: (data, timestamp)}
+_oidc_jwks_cache: dict = {} # {jwks_uri: (keys, timestamp)}
+_OIDC_CACHE_TTL = 3600 # 1 hour
+_oidc_pending: dict = {} # {state: {"nonce": str, "created": float}}
+_oidc_pending_lock = threading.Lock()
+_OIDC_STATE_TTL = 300 # 5 minutes
+
+def _get_oidc_config() -> dict:
+ keys = ("auth_mode","oidc_issuer","oidc_client_id","oidc_client_secret",
+ "oidc_redirect_uri","oidc_group_admin","oidc_group_user","oidc_group_viewer")
+ cfg = {}
+ with db() as c:
+ for k in keys:
+ row = c.execute("SELECT value FROM app_settings WHERE key=?", (k,)).fetchone()
+ cfg[k] = row["value"] if row else ""
+ if cfg.get("oidc_client_secret"):
+ cfg["oidc_client_secret"] = _safe_dec(cfg["oidc_client_secret"])
+ return cfg
+
+def _oidc_discover(issuer: str) -> dict:
+ import requests as req
+ now = time.time()
+ cached = _oidc_discovery_cache.get(issuer)
+ if cached and now - cached[1] < _OIDC_CACHE_TTL:
+ return cached[0]
+ url = issuer.rstrip("/") + "/.well-known/openid-configuration"
+ resp = req.get(url, timeout=10)
+ resp.raise_for_status()
+ data = resp.json()
+ _oidc_discovery_cache[issuer] = (data, now)
+ return data
+
+def _oidc_get_jwks(jwks_uri: str) -> list:
+ import requests as req
+ now = time.time()
+ cached = _oidc_jwks_cache.get(jwks_uri)
+ if cached and now - cached[1] < _OIDC_CACHE_TTL:
+ return cached[0]
+ resp = req.get(jwks_uri, timeout=10)
+ resp.raise_for_status()
+ keys = resp.json().get("keys", [])
+ _oidc_jwks_cache[jwks_uri] = (keys, now)
+ return keys
+
+def _oidc_store_state(state: str, nonce: str):
+ with _oidc_pending_lock:
+ now = time.time()
+ expired = [k for k, v in _oidc_pending.items() if now - v["created"] > _OIDC_STATE_TTL]
+ for k in expired:
+ del _oidc_pending[k]
+ _oidc_pending[state] = {"nonce": nonce, "created": now}
+
+def _oidc_pop_state(state: str) -> dict | None:
+ with _oidc_pending_lock:
+ return _oidc_pending.pop(state, None)
+
+def _validate_id_token(id_token: str, issuer: str, client_id: str, nonce: str) -> dict:
+ from jwt.algorithms import RSAAlgorithm
+ disco = _oidc_discover(issuer)
+ jwks = _oidc_get_jwks(disco["jwks_uri"])
+ header = pyjwt.get_unverified_header(id_token)
+ kid = header.get("kid")
+ key_data = None
+ for k in jwks:
+ if k.get("kid") == kid:
+ key_data = k
+ break
+ if not key_data:
+ raise HTTPException(401, "OIDC: chave não encontrada no JWKS")
+ public_key = RSAAlgorithm.from_jwk(json.dumps(key_data))
+ claims = pyjwt.decode(id_token, public_key, algorithms=["RS256"], audience=client_id, issuer=issuer)
+ if claims.get("nonce") != nonce:
+ raise HTTPException(401, "OIDC: nonce inválido")
+ return claims
+
# ── OCI SDK Client Helper ─────────────────────────────────────────────────────
def _get_oci_config(oci_config_id: str) -> dict:
import oci
@@ -776,6 +857,10 @@ def _check_rate_limit(ip: str):
@app.post("/api/auth/login")
async def login(req: LoginReq, request: Request):
_check_rate_limit(request.client.host if request.client else "unknown")
+ with db() as c:
+ mode_row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
+ if mode_row and mode_row["value"] == "oidc":
+ raise HTTPException(400, "Login local desabilitado. Use Oracle IAM.")
with db() as c:
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
@@ -789,7 +874,8 @@ async def login(req: LoginReq, request: Request):
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
return {"token":_make_token(u["id"],u["role"],sid),
- "user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")}}
+ "user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"]),"timezone":u.get("timezone","America/Sao_Paulo")},
+ "must_change_password": bool(u.get("must_change_password", 0))}
@app.post("/api/auth/logout")
async def logout_ep(u=Depends(current_user)):
@@ -809,10 +895,144 @@ async def register(req: RegisterReq, adm=Depends(require("admin"))):
@app.post("/api/auth/change-password")
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
+ if u.get("auth_provider") == "oidc":
+ raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente")
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
- with db() as c: c.execute("UPDATE users SET password_hash=? WHERE id=?", (_hash_pw(req.new_password), u["id"]))
+ with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(req.new_password), u["id"]))
return {"ok": True}
+# ── OIDC endpoints ───────────────────────────────────────────────────────────
+@app.get("/api/auth/oidc/config")
+async def oidc_public_config():
+ with db() as c:
+ row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
+ return {"auth_mode": row["value"] if row else "local"}
+
+@app.get("/api/auth/oidc/login")
+async def oidc_login(request: Request):
+ _check_rate_limit(request.client.host if request.client else "unknown")
+ cfg = _get_oidc_config()
+ if cfg.get("auth_mode") not in ("oidc", "both"):
+ raise HTTPException(400, "OIDC login não está habilitado")
+ if not cfg.get("oidc_issuer") or not cfg.get("oidc_client_id"):
+ raise HTTPException(500, "OIDC não configurado (issuer/client_id ausente)")
+ disco = _oidc_discover(cfg["oidc_issuer"])
+ state = secrets.token_urlsafe(32)
+ nonce = secrets.token_urlsafe(32)
+ _oidc_store_state(state, nonce)
+ from urllib.parse import urlencode
+ params = {"response_type": "code", "client_id": cfg["oidc_client_id"],
+ "redirect_uri": cfg["oidc_redirect_uri"], "scope": "openid profile email groups",
+ "state": state, "nonce": nonce}
+ auth_url = disco["authorization_endpoint"] + "?" + urlencode(params)
+ return RedirectResponse(url=auth_url, status_code=302)
+
+@app.get("/api/auth/oidc/callback")
+async def oidc_callback(request: Request, code: str = Query(...), state: str = Query(...)):
+ ip = request.client.host if request.client else "unknown"
+ _check_rate_limit(ip)
+ pending = _oidc_pop_state(state)
+ if not pending:
+ raise HTTPException(400, "OIDC: state inválido ou expirado")
+ nonce = pending["nonce"]
+ cfg = _get_oidc_config()
+ disco = _oidc_discover(cfg["oidc_issuer"])
+ import requests as req
+ token_resp = req.post(disco["token_endpoint"], data={
+ "grant_type": "authorization_code", "code": code,
+ "redirect_uri": cfg["oidc_redirect_uri"],
+ "client_id": cfg["oidc_client_id"], "client_secret": cfg["oidc_client_secret"],
+ }, timeout=15)
+ if token_resp.status_code != 200:
+ log.error(f"OIDC token exchange failed: {token_resp.status_code} {token_resp.text[:500]}")
+ raise HTTPException(502, "OIDC: falha ao trocar código por token")
+ tokens = token_resp.json()
+ id_token_raw = tokens.get("id_token")
+ if not id_token_raw:
+ raise HTTPException(502, "OIDC: id_token ausente na resposta")
+ claims = _validate_id_token(id_token_raw, cfg["oidc_issuer"], cfg["oidc_client_id"], nonce)
+ sub = claims.get("sub")
+ email = claims.get("email", "")
+ given_name = claims.get("given_name") or claims.get("name", "").split(" ")[0] or "OIDC"
+ family_name = claims.get("family_name") or " ".join(claims.get("name", "User").split(" ")[1:]) or "User"
+ groups = claims.get("groups", [])
+ if not groups:
+ groups = claims.get("http://www.oracle.com/groups", [])
+ role = "viewer"
+ if cfg.get("oidc_group_admin") and cfg["oidc_group_admin"] in groups:
+ role = "admin"
+ elif cfg.get("oidc_group_user") and cfg["oidc_group_user"] in groups:
+ role = "user"
+ with db() as c:
+ u = c.execute("SELECT * FROM users WHERE oidc_subject=? AND is_active=1", (sub,)).fetchone()
+ if u:
+ u = dict(u)
+ c.execute("UPDATE users SET role=?, last_login=datetime('now'), email=? WHERE id=?",
+ (role, email or u.get("email"), u["id"]))
+ u["role"] = role
+ _audit(u["id"], u["username"], "oidc_login", details=f"sub={sub}", ip=ip)
+ else:
+ uid = str(uuid.uuid4())
+ username = email.split("@")[0] if email else f"oidc_{sub[:8]}"
+ base_username = username
+ counter = 1
+ while c.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
+ username = f"{base_username}_{counter}"
+ counter += 1
+ fake_hash = _hash_pw(secrets.token_hex(32))
+ c.execute(
+ "INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,auth_provider,oidc_subject) "
+ "VALUES (?,?,?,?,?,?,?,?,?)",
+ (uid, given_name, family_name, username, email, fake_hash, role, "oidc", sub))
+ u = {"id": uid, "first_name": given_name, "last_name": family_name,
+ "username": username, "email": email, "role": role}
+ _audit(uid, username, "oidc_jit_provision", details=f"sub={sub} email={email} role={role}", ip=ip)
+ c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (uid,))
+ sid = str(uuid.uuid4())
+ exp = (datetime.utcnow() + timedelta(hours=JWT_EXP_H)).isoformat()
+ with db() as c:
+ c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
+ token = _make_token(u["id"], u["role"], sid)
+ html = f"""OIDC Login
+
+"""
+ return HTMLResponse(content=html)
+
+@app.post("/api/auth/oidc/logout")
+async def oidc_logout(u=Depends(current_user)):
+ with db() as c:
+ c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
+ _audit(u["id"], u["username"], "oidc_logout")
+ idp_logout_url = None
+ try:
+ cfg = _get_oidc_config()
+ if cfg.get("oidc_issuer"):
+ disco = _oidc_discover(cfg["oidc_issuer"])
+ idp_logout_url = disco.get("end_session_endpoint")
+ except Exception:
+ pass
+ return {"ok": True, "idp_logout_url": idp_logout_url}
+
+@app.post("/api/settings/oidc/test")
+async def test_oidc(u=Depends(require("admin"))):
+ cfg = _get_oidc_config()
+ if not cfg.get("oidc_issuer"):
+ raise HTTPException(400, "Issuer URL não configurado")
+ import requests as req
+ try:
+ url = cfg["oidc_issuer"].rstrip("/") + "/.well-known/openid-configuration"
+ resp = req.get(url, timeout=10)
+ resp.raise_for_status()
+ disco = resp.json()
+ has_auth = bool(disco.get("authorization_endpoint"))
+ has_token = bool(disco.get("token_endpoint"))
+ has_jwks = bool(disco.get("jwks_uri"))
+ return {"ok": has_auth and has_token and has_jwks, "issuer": disco.get("issuer"),
+ "authorization_endpoint": has_auth, "token_endpoint": has_token,
+ "jwks_uri": has_jwks, "end_session_endpoint": bool(disco.get("end_session_endpoint"))}
+ except Exception as e:
+ raise HTTPException(502, f"Falha ao conectar ao discovery: {str(e)}")
+
# ── MFA ───────────────────────────────────────────────────────────────────────
@app.post("/api/mfa/setup")
async def mfa_setup(u=Depends(current_user)):
@@ -836,13 +1056,15 @@ async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
# ── Users ─────────────────────────────────────────────────────────────────────
@app.get("/api/users")
async def list_users(u=Depends(require("admin"))):
- with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login FROM users").fetchall()
+ with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,timezone,is_active,created_at,last_login,auth_provider FROM users").fetchall()
return [dict(r) for r in rows]
@app.get("/api/users/me")
async def me(u=Depends(current_user)):
- fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider")
- return {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
+ fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider","must_change_password")
+ r = {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
+ r["must_change_password"] = bool(r.get("must_change_password", 0))
+ return r
@app.put("/api/users/{uid}")
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
@@ -9976,11 +10198,18 @@ async def get_setting(key: str, u=Depends(current_user)):
raise HTTPException(403, "Acesso negado a configuração sensível")
with db() as c:
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
- return {"key": key, "value": row["value"] if row else ""}
+ val = row["value"] if row else ""
+ if key == "oidc_client_secret" and val:
+ val = _mask(_safe_dec(val))
+ return {"key": key, "value": val}
@app.put("/api/settings/{key}")
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
value = body.get("value", "")
+ if key == "oidc_client_secret" and value and "\u2022" in value:
+ return {"key": key, "value": value}
+ if key == "oidc_client_secret" and value:
+ value = _enc(value)
with db() as c:
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value))
return {"key": key, "value": value}
diff --git a/docker-compose.yml b/docker-compose.yml
index 71594ce..e4de6a6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,7 @@ services:
container_name: oci-agent-backend
restart: unless-stopped
environment:
- - APP_SECRET=${APP_SECRET:-change-me-in-production-use-a-long-random-string}
+ - APP_SECRET=${APP_SECRET:?Set APP_SECRET in .env (openssl rand -hex 64)}
- JWT_EXPIRY_HOURS=${JWT_EXPIRY_HOURS:-12}
- DATA_DIR=/data
- CORS_ORIGINS=${CORS_ORIGINS:-}
diff --git a/frontend-react/src/App.tsx b/frontend-react/src/App.tsx
index 4471cee..2629fb2 100644
--- a/frontend-react/src/App.tsx
+++ b/frontend-react/src/App.tsx
@@ -1,9 +1,10 @@
-import { useEffect, lazy, Suspense } from 'react';
+import { useEffect, useState, lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app';
+import { authApi } from '@/api/endpoints/auth';
import AppShell from '@/components/layout/AppShell';
-import { Loader2 } from 'lucide-react';
+import { Loader2, Lock } from 'lucide-react';
// Lazy-loaded pages (code splitting)
const LoginPage = lazy(() => import('@/pages/LoginPage'));
@@ -34,10 +35,72 @@ function PageLoader() {
);
}
+function ForcePasswordModal() {
+ const [curPw, setCurPw] = useState('');
+ const [newPw, setNewPw] = useState('');
+ const [confirmPw, setConfirmPw] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const clearMustChangePassword = useAuthStore((s) => s.clearMustChangePassword);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ if (newPw.length < 8) { setError('A nova senha deve ter no minimo 8 caracteres'); return; }
+ if (newPw !== confirmPw) { setError('As senhas nao coincidem'); return; }
+ if (newPw === curPw) { setError('A nova senha deve ser diferente da atual'); return; }
+ setLoading(true);
+ try {
+ await authApi.changePassword(curPw, newPw);
+ clearMustChangePassword();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Erro ao alterar senha');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
Alterar Senha
+
+ Por seguranca, altere sua senha antes de continuar.
+
+
+
+
+
+ );
+}
+
export default function App() {
const checkAuth = useAuthStore((s) => s.checkAuth);
const loadData = useAppStore((s) => s.loadData);
const user = useAuthStore((s) => s.user);
+ const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
useEffect(() => {
checkAuth().then(() => {
@@ -51,6 +114,7 @@ export default function App() {
return (
}>
+ {user && mustChangePassword && }
} />
}>
diff --git a/frontend-react/src/api/endpoints/auth.ts b/frontend-react/src/api/endpoints/auth.ts
index 896ed03..7601ce6 100644
--- a/frontend-react/src/api/endpoints/auth.ts
+++ b/frontend-react/src/api/endpoints/auth.ts
@@ -10,6 +10,7 @@ export interface User {
mfa_enabled?: boolean;
timezone?: string;
auth_provider?: string;
+ must_change_password?: boolean;
}
export interface LoginResponse {
@@ -18,6 +19,7 @@ export interface LoginResponse {
mfa_required?: boolean;
mfa_setup?: boolean;
totp_uri?: string;
+ must_change_password?: boolean;
}
export const authApi = {
@@ -26,6 +28,9 @@ export const authApi = {
logout: () => client.post('/auth/logout'),
+ oidcLogout: () =>
+ client.post('/auth/oidc/logout'),
+
me: () => client.get('/users/me'),
changePassword: (current_password: string, new_password: string) =>
diff --git a/frontend-react/src/i18n/en.ts b/frontend-react/src/i18n/en.ts
index b332b49..2adfada 100644
--- a/frontend-react/src/i18n/en.ts
+++ b/frontend-react/src/i18n/en.ts
@@ -45,6 +45,8 @@ const en: Record = {
'login.verify': 'Verify',
'login.submit': 'Sign in',
'login.error': 'Login failed',
+ 'login.or': 'or',
+ 'login.oidcButton': 'Sign in with Oracle IAM',
// ── Chat ──
'chat.timeNow': 'now',
diff --git a/frontend-react/src/i18n/es.ts b/frontend-react/src/i18n/es.ts
index cd8d1e7..ec0ea53 100644
--- a/frontend-react/src/i18n/es.ts
+++ b/frontend-react/src/i18n/es.ts
@@ -45,6 +45,8 @@ const es: Record = {
'login.verify': 'Verificar',
'login.submit': 'Iniciar sesion',
'login.error': 'Error de inicio de sesion',
+ 'login.or': 'o',
+ 'login.oidcButton': 'Iniciar sesion con Oracle IAM',
// ── Chat ──
'chat.timeNow': 'ahora',
diff --git a/frontend-react/src/i18n/pt.ts b/frontend-react/src/i18n/pt.ts
index 96c2af5..3ac2157 100644
--- a/frontend-react/src/i18n/pt.ts
+++ b/frontend-react/src/i18n/pt.ts
@@ -45,6 +45,8 @@ const pt: Record = {
'login.verify': 'Verificar',
'login.submit': 'Entrar',
'login.error': 'Erro ao fazer login',
+ 'login.or': 'ou',
+ 'login.oidcButton': 'Login com Oracle IAM',
// ── Chat ──
'chat.timeNow': 'agora',
diff --git a/frontend-react/src/pages/LoginPage.tsx b/frontend-react/src/pages/LoginPage.tsx
index a7d4c9d..4e9ab69 100644
--- a/frontend-react/src/pages/LoginPage.tsx
+++ b/frontend-react/src/pages/LoginPage.tsx
@@ -1,5 +1,6 @@
-import { useState, type FormEvent } from 'react';
+import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
+import { Shield } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import { useAppStore } from '@/stores/app';
import { useI18n } from '@/i18n';
@@ -15,6 +16,11 @@ export default function LoginPage() {
const [totp, setTotp] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
+ const [authMode, setAuthMode] = useState('local');
+
+ useEffect(() => {
+ fetch('/api/auth/oidc/config').then(r => r.json()).then(d => setAuthMode(d.auth_mode || 'local')).catch(() => {});
+ }, []);
if (user) {
navigate('/chat', { replace: true });
@@ -57,71 +63,94 @@ export default function LoginPage() {
{t('login.subtitle')}
-
+
+
+ )}
+
+ {(authMode === 'oidc' || authMode === 'both') && (
+ <>
+ {authMode === 'both' && (
+
+ )}
+
+ >
+ )}
);
diff --git a/frontend-react/src/pages/admin/OracleIamPage.tsx b/frontend-react/src/pages/admin/OracleIamPage.tsx
index c58b63c..69bba8f 100644
--- a/frontend-react/src/pages/admin/OracleIamPage.tsx
+++ b/frontend-react/src/pages/admin/OracleIamPage.tsx
@@ -65,9 +65,8 @@ export default function OracleIamPage() {
setSaving(true);
try {
await Promise.all(
- SETTINGS_KEYS.map(k =>
- client.put(`/settings/${k}`, { value: (config as any)[k] || '' })
- )
+ SETTINGS_KEYS.filter(k => !(k === 'oidc_client_secret' && (config as any)[k]?.includes('\u2022')))
+ .map(k => client.put(`/settings/${k}`, { value: (config as any)[k] || '' }))
);
setMsg({ text: t('iam.saved') || 'Configuração salva com sucesso', type: 'success' });
} catch (err) {
@@ -84,12 +83,12 @@ export default function OracleIamPage() {
}
setTesting(true);
try {
- const url = config.oidc_issuer.replace(/\/$/, '') + '/.well-known/openid-configuration';
- const resp = await fetch(url);
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const disco = await resp.json();
- if (disco.authorization_endpoint && disco.token_endpoint) {
- setMsg({ text: t('iam.testOk') || `Conexão OK — ${disco.issuer}`, type: 'success' });
+ const resp = await client.post('/settings/oidc/test') as unknown as {
+ ok: boolean; issuer: string;
+ authorization_endpoint: boolean; token_endpoint: boolean; jwks_uri: boolean;
+ };
+ if (resp.ok) {
+ setMsg({ text: `${t('iam.testOk') || 'Conexão OK'} — ${resp.issuer}`, type: 'success' });
} else {
setMsg({ text: t('iam.testInvalid') || 'Resposta inválida do discovery endpoint', type: 'error' });
}
diff --git a/frontend-react/src/pages/admin/UsersPage.tsx b/frontend-react/src/pages/admin/UsersPage.tsx
index b173e0d..58962f9 100644
--- a/frontend-react/src/pages/admin/UsersPage.tsx
+++ b/frontend-react/src/pages/admin/UsersPage.tsx
@@ -16,6 +16,7 @@ interface AppUser {
is_active: boolean | number;
created_at: string | null;
last_login: string | null;
+ auth_provider?: string;
}
type Role = 'admin' | 'user' | 'viewer';
@@ -409,6 +410,10 @@ export default function UsersPage() {
{u.username}
+ {u.auth_provider === 'oidc' && (
+ OIDC
+ )}
{/* Email */}
diff --git a/frontend-react/src/stores/auth.ts b/frontend-react/src/stores/auth.ts
index 262083f..52b664a 100644
--- a/frontend-react/src/stores/auth.ts
+++ b/frontend-react/src/stores/auth.ts
@@ -8,11 +8,13 @@ interface AuthState {
mfaRequired: boolean;
mfaSetup: boolean;
totpUri: string | null;
+ mustChangePassword: boolean;
login: (username: string, password: string, totp?: string) => Promise;
logout: () => Promise;
checkAuth: () => Promise;
setToken: (token: string) => void;
+ clearMustChangePassword: () => void;
}
export const useAuthStore = create((set, get) => ({
@@ -22,6 +24,7 @@ export const useAuthStore = create((set, get) => ({
mfaRequired: false,
mfaSetup: false,
totpUri: null,
+ mustChangePassword: false,
login: async (username, password, totp) => {
const res = await authApi.login(username, password, totp);
@@ -31,15 +34,26 @@ export const useAuthStore = create((set, get) => ({
}
if (res.token && res.user) {
localStorage.setItem('t', res.token);
- set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null });
+ set({ token: res.token, user: res.user, mfaRequired: false, mfaSetup: false, totpUri: null,
+ mustChangePassword: !!res.must_change_password });
}
return res;
},
logout: async () => {
- try { await authApi.logout(); } catch {}
+ const currentUser = get().user;
+ try {
+ if (currentUser?.auth_provider === 'oidc') {
+ const res = await authApi.oidcLogout();
+ localStorage.removeItem('t');
+ set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
+ if (res.idp_logout_url) window.location.href = res.idp_logout_url;
+ return;
+ }
+ await authApi.logout();
+ } catch {}
localStorage.removeItem('t');
- set({ user: null, token: null, mfaRequired: false });
+ set({ user: null, token: null, mfaRequired: false, mustChangePassword: false });
},
checkAuth: async () => {
@@ -47,7 +61,7 @@ export const useAuthStore = create((set, get) => ({
if (!token) { set({ loading: false }); return; }
try {
const user = await authApi.me();
- set({ user, loading: false });
+ set({ user, loading: false, mustChangePassword: !!user.must_change_password });
} catch {
localStorage.removeItem('t');
set({ token: null, user: null, loading: false });
@@ -58,4 +72,6 @@ export const useAuthStore = create((set, get) => ({
localStorage.setItem('t', token);
set({ token });
},
+
+ clearMustChangePassword: () => set({ mustChangePassword: false }),
}));