From 02c0a8f1b72783fe3900099e3ad22c0439841e64 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 28 Apr 2026 17:36:44 +0100 Subject: [PATCH 1/6] Add OIDC/OAuth2 authentication via Authlib Replace WEB_ADMIN_ENABLED with full OIDC support using Authlib. Admin access now requires authenticated sessions with IdP-assigned roles instead of an open toggle. - Add authlib and itsdangerous dependencies - Add OIDC settings to WebSettings (13 env vars) - Create web/oidc.py module (OAuth registry, session helpers) - Add /auth/login, /auth/callback, /auth/logout, /auth/user routes - Gate API proxy writes to admin sessions when OIDC enabled - Protect /a/ routes with session check (redirect to login) - Add SessionMiddleware for signed session cookies - Add renderAuthSection navbar component (login/avatar dropdown) - Add 401/403 interceptor in api.js for auto-redirect - Exclude /auth/ from SPA client-side router interception - Render auth section after translations load (fixes raw key display) - Add custom error pages for 500s (standalone HTML, no JS deps) - Update docker-compose.yml to pass OIDC_* env vars to web container - Update .env.example, README, AGENTS.md, upgrading.md, i18n.md - Add auth.* and errors.* i18n keys - Add 200 tests (OIDC, admin, error pages) --- .../references/docker-source-guide.md | 13 +- .env.example | 52 +- AGENTS.md | 15 +- README.md | 13 +- docker-compose.yml | 14 +- docs/i18n.md | 20 +- .../20260428-1300-oidc-oauth-support/plan.md | 585 ++++++++++++++++++ .../20260428-1300-oidc-oauth-support/tasks.md | 111 ++++ docs/upgrading.md | 38 ++ pyproject.toml | 3 + src/meshcore_hub/common/config.py | 37 +- src/meshcore_hub/web/app.py | 235 ++++++- src/meshcore_hub/web/cli.py | 2 + src/meshcore_hub/web/oidc.py | 65 ++ src/meshcore_hub/web/static/js/spa/api.js | 15 + src/meshcore_hub/web/static/js/spa/app.js | 19 +- .../web/static/js/spa/components.js | 45 ++ .../web/static/js/spa/pages/admin/index.js | 15 +- .../web/static/js/spa/pages/admin/members.js | 6 +- .../static/js/spa/pages/admin/node-tags.js | 6 +- .../web/static/js/spa/pages/node-detail.js | 2 +- src/meshcore_hub/web/static/js/spa/router.js | 2 +- src/meshcore_hub/web/static/locales/en.json | 19 +- src/meshcore_hub/web/templates/error.html | 93 +++ src/meshcore_hub/web/templates/spa.html | 5 +- tests/test_web/conftest.py | 83 ++- tests/test_web/test_admin.py | 79 ++- tests/test_web/test_error_pages.py | 133 ++++ tests/test_web/test_oidc.py | 264 ++++++++ 29 files changed, 1914 insertions(+), 75 deletions(-) create mode 100644 docs/plans/20260428-1300-oidc-oauth-support/plan.md create mode 100644 docs/plans/20260428-1300-oidc-oauth-support/tasks.md create mode 100644 src/meshcore_hub/web/oidc.py create mode 100644 src/meshcore_hub/web/templates/error.html create mode 100644 tests/test_web/test_error_pages.py create mode 100644 tests/test_web/test_oidc.py diff --git a/.agents/skills/docs-sync/references/docker-source-guide.md b/.agents/skills/docs-sync/references/docker-source-guide.md index 7eb8746..5bcbb0a 100644 --- a/.agents/skills/docs-sync/references/docker-source-guide.md +++ b/.agents/skills/docs-sync/references/docker-source-guide.md @@ -193,7 +193,18 @@ Complete list from `docker-compose.yml` collector `environment:` block: | `WEB_THEME` | `dark` | Theme | | `WEB_LOCALE` | `en` | Locale | | `WEB_DATETIME_LOCALE` | `en-US` | Locale | -| `WEB_ADMIN_ENABLED` | `false` | Admin | +| `OIDC_ENABLED` | `false` | Auth | +| `OIDC_CLIENT_ID` | (empty) | Auth | +| `OIDC_CLIENT_SECRET` | (empty) | Auth | +| `OIDC_DISCOVERY_URL` | (empty) | Auth | +| `OIDC_REDIRECT_URI` | (empty) | Auth | +| `OIDC_SCOPES` | `openid email profile` | Auth | +| `OIDC_ROLES_CLAIM` | `roles` | Auth | +| `OIDC_ADMIN_ROLE` | `admin` | Auth | +| `OIDC_MEMBER_ROLE` | `member` | Auth | +| `OIDC_SESSION_SECRET` | (empty) | Auth | +| `OIDC_SESSION_MAX_AGE` | `86400` | Auth | +| `OIDC_COOKIE_SECURE` | `false` | Auth | | `NETWORK_NAME` | `MeshCore Network` | Network | | `NETWORK_CITY` | (empty) | Network | | `NETWORK_COUNTRY` | (empty) | Network | diff --git a/.env.example b/.env.example index 556c6fe..28d57fa 100644 --- a/.env.example +++ b/.env.example @@ -335,9 +335,57 @@ WEB_PORT=8080 # Default: 30 # WEB_AUTO_REFRESH_SECONDS=30 -# Enable admin interface at /a/ +# ------------------- +# OIDC Authentication +# ------------------- +# Enable OIDC/OAuth2 authentication for the web dashboard. +# When enabled, the admin interface (/a/) requires authenticated sessions. +# Requires an OIDC-compliant identity provider (e.g., LogTo, Keycloak). + +# Enable OIDC authentication # Default: false -# WEB_ADMIN_ENABLED=false +# OIDC_ENABLED=false + +# OIDC client ID (from your IdP) +# OIDC_CLIENT_ID= + +# OIDC client secret (from your IdP) +# OIDC_CLIENT_SECRET= + +# OIDC discovery URL (your IdP's .well-known/openid-configuration endpoint) +# OIDC_DISCOVERY_URL= + +# OIDC callback URL (overrides auto-derivation from request) +# Example: https://hub.example.com/auth/callback +# OIDC_REDIRECT_URI= + +# OAuth scopes to request +# Default: openid email profile +# OIDC_SCOPES=openid email profile + +# ID token claim name containing user roles +# Default: roles +# OIDC_ROLES_CLAIM=roles + +# Role value that grants admin access +# Default: admin +# OIDC_ADMIN_ROLE=admin + +# Role value that grants member access +# Default: member +# OIDC_MEMBER_ROLE=member + +# Secret for signing session cookies (required when OIDC_ENABLED=true) +# Generate with: openssl rand -hex 32 +# OIDC_SESSION_SECRET= + +# Session cookie lifetime in seconds +# Default: 86400 (24 hours) +# OIDC_SESSION_MAX_AGE=86400 + +# HTTPS-only session cookies (enable in production behind TLS) +# Default: false +# OIDC_COOKIE_SECURE=false # Timezone for displaying dates/times on the web dashboard # Uses standard IANA timezone names (e.g., America/New_York, Europe/London) diff --git a/AGENTS.md b/AGENTS.md index 3bd8dec..558fcbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -637,7 +637,18 @@ Key variables: - `COLLECTOR_CHANNEL_KEYS` - Additional decoder channel keys for decrypting GroupText packets - `COLLECTOR_INCLUDE_TEST_CHANNEL` - Include built-in 'test' channel messages (default: `false`) - `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys -- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`) +- `OIDC_ENABLED` - Enable OIDC authentication (default: `false`) +- `OIDC_CLIENT_ID` - OIDC client ID (required if OIDC_ENABLED=true) +- `OIDC_CLIENT_SECRET` - OIDC client secret (required if OIDC_ENABLED=true) +- `OIDC_DISCOVERY_URL` - OIDC discovery URL (required if OIDC_ENABLED=true) +- `OIDC_REDIRECT_URI` - Explicit callback URL (overrides auto-derivation) +- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`) +- `OIDC_ROLES_CLAIM` - ID token claim for roles (default: `roles`) +- `OIDC_ADMIN_ROLE` - Role value for admin access (default: `admin`) +- `OIDC_MEMBER_ROLE` - Role value for member access (default: `member`) +- `OIDC_SESSION_SECRET` - Secret for signing session cookies (required if OIDC_ENABLED=true) +- `OIDC_SESSION_MAX_AGE` - Session lifetime in seconds (default: `86400`) +- `OIDC_COOKIE_SECURE` - HTTPS-only cookies (default: `false`) - `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage. - `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable) - `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`) @@ -690,7 +701,7 @@ meshcore-hub collector seed docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile seed up ``` -**Note:** Once the admin UI is enabled (`WEB_ADMIN_ENABLED=true`), tags should be managed through the web interface rather than seed files. +**Note:** When OIDC is enabled (`OIDC_ENABLED=true`), the admin UI requires authenticated sessions with the `admin` role. Tags should be managed through the web interface by authenticated admin users. ### Webhook Configuration diff --git a/README.md b/README.md index 2a1713b..bb724c0 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,18 @@ The collector automatically cleans up old event data and inactive nodes: | `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) | | `WEB_DATETIME_LOCALE` | `en-US` | Locale used for date formatting in the web dashboard (e.g., `en-US` for MM/DD/YYYY, `en-GB` for DD/MM/YYYY). | | `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) | -| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ | +| `OIDC_ENABLED` | `false` | Enable OIDC authentication for the web dashboard | +| `OIDC_CLIENT_ID` | _(none)_ | OIDC client ID (from IdP, required when OIDC_ENABLED=true) | +| `OIDC_CLIENT_SECRET` | _(none)_ | OIDC client secret (from IdP, required when OIDC_ENABLED=true) | +| `OIDC_DISCOVERY_URL` | _(none)_ | IdP's `.well-known/openid-configuration` URL (required when OIDC_ENABLED=true) | +| `OIDC_REDIRECT_URI` | _(auto-derived)_ | Explicit callback URL (overrides auto-derivation from request) | +| `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request | +| `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles | +| `OIDC_ADMIN_ROLE` | `admin` | Role value granting admin access | +| `OIDC_MEMBER_ROLE` | `member` | Role value granting member access | +| `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (required when OIDC_ENABLED=true) | +| `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default 24 hours) | +| `OIDC_COOKIE_SECURE` | `false` | HTTPS-only session cookies (enable in production) | | `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) | | `NETWORK_DOMAIN` | _(none)_ | Network domain name (optional) | | `NETWORK_NAME` | `MeshCore Network` | Display name for the network | diff --git a/docker-compose.yml b/docker-compose.yml index 01d7361..5c15e59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -265,7 +265,19 @@ services: - WEB_THEME=${WEB_THEME:-dark} - WEB_LOCALE=${WEB_LOCALE:-en} - WEB_DATETIME_LOCALE=${WEB_DATETIME_LOCALE:-en-US} - - WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false} + # OIDC authentication (see .env.example for details) + - OIDC_ENABLED=${OIDC_ENABLED:-false} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} + - OIDC_SCOPES=${OIDC_SCOPES:-openid email profile} + - OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM:-roles} + - OIDC_ADMIN_ROLE=${OIDC_ADMIN_ROLE:-admin} + - OIDC_MEMBER_ROLE=${OIDC_MEMBER_ROLE:-member} + - OIDC_SESSION_SECRET=${OIDC_SESSION_SECRET:-} + - OIDC_SESSION_MAX_AGE=${OIDC_SESSION_MAX_AGE:-86400} + - OIDC_COOKIE_SECURE=${OIDC_COOKIE_SECURE:-false} - NETWORK_NAME=${NETWORK_NAME:-MeshCore Network} - NETWORK_CITY=${NETWORK_CITY:-} - NETWORK_COUNTRY=${NETWORK_COUNTRY:-} diff --git a/docs/i18n.md b/docs/i18n.md index 50e013a..2602dd3 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -339,12 +339,28 @@ Admin panel content: |-----|---------|---------| | `access_denied` | Access Denied | Access denied heading | | `admin_not_enabled` | The admin interface is not enabled. | Admin disabled message | -| `admin_enable_hint` | Set WEB_ADMIN_ENABLED=true to enable admin features. | Configuration hint (HTML allowed) | +| `admin_enable_hint` | Set OIDC_ENABLED=true and configure your OIDC provider to enable admin features. | Configuration hint (HTML allowed) | | `welcome` | Welcome to the admin panel. | Admin welcome message | | `members_description` | Manage network members and operators. | Members card description | | `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description | -### 17. `admin_members` +### 17. `auth` + +Authentication UI: + +| Key | English | Context | +|-----|---------|---------| +| `login` | Login | Login button text | +| `logout` | Logout | Logout menu item | +| `login_required` | Login required | Login required heading | +| `admin_required` | Admin access required | Admin access denied message | +| `login_hint` | Log in to access admin features | Hint shown to non-admin users | +| `logged_in_as` | Logged in as {{name}} | Logged in status ({{name}} = user display name) | +| `session_expired` | Session expired, please log in again | Session expiry notice | +| `role_admin` | admin | Admin role badge text | +| `role_member` | member | Member role badge text | + +### 18. `admin_members` Admin members page: diff --git a/docs/plans/20260428-1300-oidc-oauth-support/plan.md b/docs/plans/20260428-1300-oidc-oauth-support/plan.md new file mode 100644 index 0000000..d5426a4 --- /dev/null +++ b/docs/plans/20260428-1300-oidc-oauth-support/plan.md @@ -0,0 +1,585 @@ +# OIDC/OAuth2 Authentication — Implementation Plan + +**Date:** 2026-04-26 +**Status:** Approved + +## Overview + +Add native OIDC/OAuth2 authentication to the web application using a standard Client ID / Client Secret pattern. Supports any OIDC-compliant identity provider (LogTo, Keycloak, etc.) via OIDC Discovery. No provider-specific libraries or integrations. + +## Library + +**Authlib** (`authlib>=1.3.0`) — chosen over alternatives: + +| Library | Rejected because | +|---|---| +| `pyoidc` | Less maintained, no native FastAPI/Starlette integration | +| `oauthlib` | Low-level; requires manual OIDC layer (Authlib wraps it) | +| `mozilla-django-oidc` | Django-specific | + +Authlib provides: + +- First-class async Starlette/FastAPI integration (`authlib.integrations.starlette_client`) +- OIDC Discovery via `server_metadata_url` — auto-configures from any provider +- ID token parsing, PKCE, token lifecycle +- No provider-specific code required + +## Decisions + +1. **OIDC replaces `WEB_ADMIN_ENABLED`** — Single toggle, removed entirely (no deprecation period, alpha release). +2. **Two roles: `member` and `admin`** — Configurable claim name and role values. +3. **No local user model** — Users managed entirely by the IdP. MeshCore Hub is a pure OIDC relying party. +4. **Session via signed cookies** — Starlette `SessionMiddleware`, no Redis/DB sessions. +5. **API unchanged** — Continues using static Bearer tokens internally. Auth gating happens at the web proxy layer. +6. **OIDC-only (no plain OAuth2 fallback)** — Provider must support OIDC Discovery. Plain OAuth2 providers (e.g. GitHub OAuth directly) are not supported. Use an IdP like LogTo to wrap social providers. +7. **All write methods gated at proxy** — POST/PUT/DELETE/PATCH through the API proxy require admin session when OIDC enabled. Simpler and more secure than path-specific gating. +8. **IdP setup is out of scope** — Configuring LogTo, Keycloak, or any other IdP is handled separately. + +## Terminology + +| Term | Meaning | +|---|---| +| IdP | Identity Provider (LogTo, Keycloak, etc.) | +| Relying Party | MeshCore Hub (the OIDC client) | +| Discovery URL | IdP's `.well-known/openid-configuration` endpoint | +| ID token | JWT issued by IdP containing user claims | +| Roles claim | ID token field containing user role assignments | + +## Role Model + +| Role | Access | IDP Configuration | +|---|---|---| +| Anonymous | Read-only dashboard (nodes, messages, map, etc.) | N/A | +| Member | Same as anonymous + future member-only features (no routes yet) | IdP assigns `member` role to user | +| Admin | `/a/*` routes + all write API proxy requests | IdP assigns `admin` role to user | + +Role assignment flow: + +1. User signs in via IdP (first sign-in = registration, handled by IdP) +2. IdP issues ID token with configured roles claim +3. MeshCore Hub reads roles from ID token, grants access accordingly +4. To promote to admin: add `admin` role in IdP admin console +5. Role changes take effect on next login (session expiry = `OIDC_SESSION_MAX_AGE`) + +## Environment Variables + +### New Variables + +| Variable | Description | Default | +|---|---|---| +| `OIDC_ENABLED` | Enable OIDC authentication | `false` | +| `OIDC_CLIENT_ID` | Client ID from IdP | (required if enabled) | +| `OIDC_CLIENT_SECRET` | Client secret from IdP | (required if enabled) | +| `OIDC_DISCOVERY_URL` | IdP's `.well-known/openid-configuration` URL | (required if enabled) | +| `OIDC_REDIRECT_URI` | Explicit callback URL (overrides auto-derivation) | (auto-derived from request) | +| `OIDC_SCOPES` | OAuth scopes to request | `openid email profile` | +| `OIDC_ROLES_CLAIM` | ID token claim name containing roles array | `roles` | +| `OIDC_ADMIN_ROLE` | Role value that grants admin access | `admin` | +| `OIDC_MEMBER_ROLE` | Role value that grants member access | `member` | +| `OIDC_SESSION_SECRET` | Secret for signing session cookies | (required if enabled) | +| `OIDC_SESSION_MAX_AGE` | Session cookie lifetime in seconds | `86400` (24 hours) | +| `OIDC_COOKIE_SECURE` | HTTPS-only session cookies | `false` | + +### Removed Variables + +| Variable | Reason | +|---|---| +| `WEB_ADMIN_ENABLED` | Replaced by `OIDC_ENABLED` | + +## Architecture + +### Auth Flow + +``` +Browser Web App (:8080) OIDC Provider (IdP) + | | | + | GET /a/node-tags | | + |------------------------------>| | + | | No session | + | 302 /auth/login?next=/a/node-tags | + |<------------------------------| | + | | | + | GET /auth/login | | + |------------------------------>| | + | | Build auth URL + state | + | 302 → IdP authorize endpoint | | + |<------------------------------| | + | | | + | User authenticates at IdP | | + |------------------------------>| | + | | | + | GET /auth/callback?code=...&state=... | + |------------------------------>| | + | | Exchange code for tokens | + | |------------------------------>| + | | ID token + access token | + | |<------------------------------| + | | Extract userinfo + roles | + | | Set session cookie | + | 302 → /a/node-tags | | + |<------------------------------| | + | | | + | GET /a/node-tags | | + |------------------------------>| | + | | Session valid, role=admin | + | | Proxy to API with api_key | + | 200 HTML | | + |<------------------------------| | +``` + +### Security Model + +``` + ┌─────────────────────────┐ + │ Web App (:8080) │ + │ Exposed to internet │ + │ │ + Browser ──────────────►│ Session cookie auth │ + │ Role-based gating on │ + │ admin routes + writes │ + │ │ + │ httpx + Bearer api_key │ + └──────────┬──────────────┘ + │ (internal network only) + ┌──────────▼──────────────┐ + │ API (:8000) │ + │ Bound to 127.0.0.1 │ + │ │ + │ Bearer token auth │ + │ (RequireRead / │ + │ RequireAdmin) │ + └─────────────────────────┘ +``` + +- Web app is the only public surface +- API bound to localhost / internal Docker network — only reachable via proxy +- Browser never sees the `API_KEY` +- Session data stored in signed cookie (no server-side state) + +### Navbar Auth UI + +Location: `navbar-end` section, to the left of the theme toggle. Only visible when `OIDC_ENABLED=true`. + +**Not logged in:** + +``` +┌─────────────────────────────────────────────┬──────────┬─────────┐ +│ ... nav links ... │ [Login] │ 🌙/☀️ │ +└─────────────────────────────────────────────┴──────────┴─────────┘ +``` + +**Logged in (admin):** + +``` +┌─────────────────────────────────────────────┬────────┬──────────┐ +│ ... nav links ... │ [JD ▼] │ 🌙/☀️ │ +└─────────────────────────────────────────────┴────────┴──────────┘ + │ John Doe (admin) │ + │ ─────────────────│ + │ Admin │ + │ Logout │ + └───────────────────┘ +``` + +**Logged in (member):** + +``` +┌─────────────────────────────────────────────┬────────┬──────────┐ +│ ... nav links ... │ [JD ▼] │ 🌙/☀️ │ +└─────────────────────────────────────────────┴────────┴──────────┘ + │ John Doe │ + │ ─────────────────│ + │ Logout │ + └───────────────────┘ +``` + +- Avatar: `user.picture` from OIDC, fallback to initials +- Role badge: `admin` = `badge-primary`, `member` = `badge-ghost` +- Dropdown: DaisyUI `dropdown` component +- Implementation: Server-side placeholder in `spa.html`, populated by `components.js` using lit-html from `config.user` + +## Implementation + +### File Changes + +| File | Change | +|---|---| +| `pyproject.toml` | Add `authlib>=1.3.0`, update mypy overrides | +| `common/config.py` | Add OIDC settings, remove `web_admin_enabled` | +| `web/oidc.py` | **New** — OIDC client init, role extraction, session helpers | +| `web/app.py` | Session middleware, auth routes, proxy write gating, config injection, remove `admin_enabled` | +| `web/cli.py` | Remove `admin_enabled` opts, add OIDC status display | +| `web/templates/spa.html` | Auth UI placeholder in navbar, remove `admin_enabled` conditionals | +| `web/static/js/spa/app.js` | Auth-aware routing | +| `web/static/js/spa/api.js` | 401 response interceptor → redirect to login | +| `web/static/js/spa/components.js` | Auth UI components (login button, user dropdown) | +| `web/static/js/spa/pages/admin/index.js` | Remove `admin_enabled` check | +| `web/static/locales/en.json` | Auth translation keys | +| `tests/test_web/conftest.py` | Replace `admin_enabled` fixtures with OIDC fixtures | +| `tests/test_web/test_admin.py` | Update for OIDC auth | +| `tests/test_web/test_oidc.py` | **New** — OIDC auth tests | +| `.env.example` | Remove `WEB_ADMIN_ENABLED`, add OIDC section | +| `README.md` | OIDC env vars, remove `WEB_ADMIN_ENABLED` | +| `docs/upgrading.md` | Migration from `WEB_ADMIN_ENABLED` | +| `AGENTS.md` | Updated env vars table, OIDC testing notes | + +### Phase 1: Dependencies & Configuration + +#### 1.1 `pyproject.toml` + +- Add `authlib>=1.3.0` to `dependencies` +- Add `authlib.*` to mypy `ignore_missing_imports` + +#### 1.2 `common/config.py` + +Add to `WebSettings`: + +```python +oidc_enabled: bool = Field(default=False, description="Enable OIDC authentication") +oidc_client_id: Optional[str] = Field(default=None, description="OIDC client ID") +oidc_client_secret: Optional[str] = Field(default=None, description="OIDC client secret") +oidc_discovery_url: Optional[str] = Field(default=None, description="OIDC discovery URL") +oidc_redirect_uri: Optional[str] = Field(default=None, description="OIDC callback URL (overrides auto-derivation)") +oidc_scopes: str = Field(default="openid email profile", description="OAuth scopes to request") +oidc_roles_claim: str = Field(default="roles", description="ID token claim containing user roles") +oidc_admin_role: str = Field(default="admin", description="Role value granting admin access") +oidc_member_role: str = Field(default="member", description="Role value granting member access") +oidc_session_secret: Optional[str] = Field(default=None, description="Secret key for signing session cookies") +oidc_session_max_age: int = Field(default=86400, description="Session cookie lifetime in seconds") +oidc_cookie_secure: bool = Field(default=False, description="HTTPS-only session cookies (enable in production)") +``` + +Remove `web_admin_enabled` from `WebSettings`. + +### Phase 2: OIDC Module + +#### 2.1 `web/oidc.py` (new) + +```python +"""OIDC/OAuth2 authentication using Authlib.""" + +import logging +from typing import Any + +from authlib.integrations.starlette_client import OAuth +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +oauth = OAuth() + + +def init_oidc(client_id: str, client_secret: str, discovery_url: str, scopes: str) -> None: + """Register the OIDC client on the OAuth registry.""" + oauth.register( + name="oidc", + client_id=client_id, + client_secret=client_secret, + server_metadata_url=discovery_url, + client_kwargs={"scope": scopes}, + ) + + +async def validate_discovery() -> bool: + """Eagerly validate OIDC discovery endpoint is reachable.""" + try: + await oauth.oidc.load_server_metadata() + return True + except Exception as e: + logger.error("OIDC discovery failed: %s", e) + return False + + +def get_session_user(request: Request) -> dict[str, Any] | None: + """Get current user from session, or None.""" + return request.session.get("user") + + +def get_user_roles(request: Request, roles_claim: str, admin_role: str, member_role: str) -> tuple[bool, bool]: + """Extract roles from session. Returns (is_member, is_admin).""" + user = get_session_user(request) + if not user: + return False, False + roles = user.get(roles_claim, []) + if isinstance(roles, str): + roles = [roles] + is_admin = admin_role in roles + is_member = member_role in roles + return is_member, is_admin + + +def strip_userinfo(userinfo: dict[str, Any], roles_claim: str) -> dict[str, Any]: + """Strip userinfo to essential fields for session storage.""" + return { + "sub": userinfo.get("sub"), + "name": userinfo.get("name"), + "email": userinfo.get("email"), + "picture": userinfo.get("picture"), + roles_claim: userinfo.get(roles_claim, []), + } +``` + +### Phase 3: Web App Changes + +#### 3.1 `web/app.py` — Session middleware + +In `create_app()`, add after `CacheControlMiddleware`: + +```python +if settings.oidc_enabled: + app.add_middleware( + SessionMiddleware, + secret_key=settings.oidc_session_secret, + session_cookie="meshcore-session", + max_age=settings.oidc_session_max_age, + same_site="lax", + https_only=settings.oidc_cookie_secure, + ) +``` + +#### 3.2 `web/app.py` — Auth routes + +Four endpoints, registered before the SPA catch-all: + +| Route | Method | Purpose | +|---|---|---| +| `/auth/login` | GET | Initiate OIDC flow, store `next` URL in session | +| `/auth/callback` | GET | Exchange code for tokens, store stripped userinfo in session | +| `/auth/logout` | GET | Clear session, redirect to IdP end_session_endpoint | +| `/auth/user` | GET | Return current user + roles as JSON | + +#### 3.3 `web/app.py` — Proxy write gating + +In `api_proxy`, gate all write methods: + +```python +if request.app.state.oidc_enabled and request.method in ("POST", "PUT", "DELETE", "PATCH"): + _, is_admin = get_user_roles(request, ...) + if not is_admin: + return JSONResponse({"detail": "Admin access required", "code": "AUTH_REQUIRED"}, status_code=403) +``` + +#### 3.4 `web/app.py` — SPA catch-all admin protection + +At top of `spa_catchall`, for `/a` paths when OIDC enabled: + +- No session → 302 to `/auth/login?next=/{path}` +- Session but not admin → serve SPA shell (client-side shows access denied) + +#### 3.5 `web/app.py` — SPA config injection + +Update `_build_config_json()`: + +```python +if request.app.state.oidc_enabled: + user = get_session_user(request) + is_member, is_admin = get_user_roles(request, ...) + config.update(oidc_enabled=True, user=user, is_member=is_member, is_admin=is_admin) +else: + config.update(oidc_enabled=False, user=None, is_member=False, is_admin=False) +``` + +Remove `admin_enabled` from config dict. + +#### 3.6 `web/app.py` — Remove `admin_enabled` + +- Remove from `create_app()` parameters and `app.state` +- Remove from template context in `spa_catchall` + +#### 3.7 `web/app.py` — Eager OIDC discovery validation + +In `lifespan()`, after HTTP client creation: + +```python +if getattr(app.state, "oidc_enabled", False): + ok = await validate_discovery() + if not ok: + logger.warning("OIDC discovery failed — login will not work until IdP is reachable") +``` + +### Phase 4: CLI + +#### 4.1 `web/cli.py` + +- Remove `--admin-enabled` option +- Add OIDC status to startup banner + +### Phase 5: Frontend + +#### 5.1 `web/templates/spa.html` + +Add auth placeholder in `navbar-end` before theme toggle: + +```html +{% if oidc_enabled %} +
+{% endif %} +``` + +Remove `{% if admin_enabled %}` conditionals from footer. + +#### 5.2 `web/static/js/spa/components.js` + +Add `renderAuthSection(container, config)` — renders login button or user dropdown based on `config.user`. + +#### 5.3 `web/static/js/spa/app.js` + +- Call `renderAuthSection()` after config load +- Check `config.is_admin` for admin routes +- Remove dependency on `config.admin_enabled` + +#### 5.4 `web/static/js/spa/api.js` + +Add 401 interceptor — detect `401` responses and redirect to `/auth/login`. + +#### 5.5 `web/static/js/spa/pages/admin/index.js` + +Remove `config.admin_enabled` check. + +### Phase 6: i18n + +#### 6.1 `web/static/locales/en.json` + +```json +{ + "auth": { + "login": "Login", + "logout": "Logout", + "login_required": "Login required", + "admin_required": "Admin access required", + "login_hint": "Log in to access admin features", + "logged_in_as": "Logged in as {{name}}", + "session_expired": "Session expired, please log in again", + "role_admin": "admin", + "role_member": "member" + } +} +``` + +### Phase 7: Tests + +#### 7.1 `tests/test_web/conftest.py` + +- Replace all `admin_enabled` parameters with `oidc_enabled` +- Add OIDC fixtures: `web_app_with_oidc`, `client_with_oidc_admin_session`, `client_with_oidc_member_session`, `client_with_oidc_no_session` + +#### 7.2 `tests/test_web/test_oidc.py` (new) + +- OIDC settings validation +- `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/user` tests +- Admin route protection (302 / 403 / 200) +- API proxy write gating +- Backward compatibility (OIDC disabled = current behavior) + +#### 7.3 `tests/test_web/test_admin.py` + +Update all tests for OIDC fixtures. + +### Phase 8: Documentation + +- `.env.example` — Remove `WEB_ADMIN_ENABLED`, add OIDC section with all 12 env vars +- `README.md` — OIDC env vars, remove `WEB_ADMIN_ENABLED` +- `docs/upgrading.md` — migration from `WEB_ADMIN_ENABLED` +- `AGENTS.md` — updated env vars table, OIDC testing notes + +## Reverse Proxy Configuration (Traefik) + +### 1. Forwarded Headers + +Traefik automatically sends `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto` headers. For `OIDC_REDIRECT_URI` auto-derivation to produce `https://` URLs, forwarded headers must be trusted. + +**Recommended**: Set `OIDC_REDIRECT_URI` explicitly to avoid relying on header forwarding: + +```yaml +environment: + OIDC_REDIRECT_URI: "https://hub.example.com/auth/callback" +``` + +### 2. Traefik Labels + +The auth callback route must be accessible without authentication. Since Traefik routes all traffic to the web app and auth gating is handled in application code, no special Traefik routing rules are needed. + +```yaml +labels: + - "traefik.http.routers.hub.rule=Host(`hub.example.com`)" + - "traefik.http.services.hub.loadbalancer.server.port=8080" +``` + +### 3. Cookie Secure Flag + +When behind Traefik with TLS termination: + +```yaml +environment: + OIDC_COOKIE_SECURE: "true" +``` + +### 4. CORS / SameSite + +Session cookies use `SameSite=Lax` which is correct for the OIDC redirect flow: + +- IdP redirect to `/auth/callback` is a top-level navigation → `SameSite=Lax` cookies are sent +- API calls from SPA are same-origin → cookies always sent +- Cross-origin cookie injection prevented by `SameSite=Lax` + +### 5. Docker Compose Example + +```yaml +services: + web: + build: . + command: meshcore-hub web + environment: + # ... existing vars ... + OIDC_ENABLED: "true" + OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}" + OIDC_DISCOVERY_URL: "https://auth.example.com/oidc/.well-known/openid-configuration" + OIDC_REDIRECT_URI: "https://hub.example.com/auth/callback" + OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}" + OIDC_COOKIE_SECURE: "true" + labels: + - "traefik.http.routers.hub.rule=Host(`hub.example.com`)" + - "traefik.http.routers.hub.tls=true" + - "traefik.http.routers.hub.tls.certresolver=letsencrypt" + - "traefik.http.services.hub.loadbalancer.server.port=8080" + networks: + - internal + - traefik + + api: + build: . + command: meshcore-hub api + environment: + API_HOST: "0.0.0.0" + API_PORT: "8000" + API_ADMIN_KEY: "${API_ADMIN_KEY}" + networks: + - internal + +networks: + internal: + internal: true + traefik: + external: true +``` + +## Known Limitations + +1. **Role changes are not instant** — Take effect on next login (up to `OIDC_SESSION_MAX_AGE` seconds). +2. **OIDC-only** — No support for plain OAuth2 providers. Use an IdP like LogTo to wrap social providers. +3. **No token refresh** — Sessions are fixed-lifetime. Token refresh can be added later. +4. **IdP availability** — If the IdP is down, users cannot log in. Anonymous access still works. Existing sessions continue until expiry. +5. **No local user store** — No user list or audit trail. Can be added later. + +## Execution Order + +1. `pyproject.toml` + `common/config.py` +2. `web/oidc.py` +3. `web/app.py` +4. `web/cli.py` +5. `web/templates/spa.html` + `web/static/js/spa/` (frontend) +6. `web/static/locales/en.json` (i18n) +7. `tests/test_web/` (tests) +8. Documentation (`.env.example`, README, upgrading, AGENTS.md) +9. `pre-commit run --all-files` + `pytest tests/test_web/` diff --git a/docs/plans/20260428-1300-oidc-oauth-support/tasks.md b/docs/plans/20260428-1300-oidc-oauth-support/tasks.md new file mode 100644 index 0000000..4b1d79e --- /dev/null +++ b/docs/plans/20260428-1300-oidc-oauth-support/tasks.md @@ -0,0 +1,111 @@ +# OIDC/OAuth2 Authentication — Task Checklist + +**Plan:** `docs/plans/20260428-1300-oidc-oauth-support/plan.md` +**Status:** In Progress + +--- + +## Phase 1: Dependencies & Configuration + +- [ ] **1.1** `pyproject.toml` — Add `authlib>=1.3.0` to `dependencies` list (after `httpx>=0.25.0`) +- [ ] **1.2** `pyproject.toml` — Add `"authlib.*"` to mypy `ignore_missing_imports` overrides module list (line 114-122) +- [ ] **1.3** `src/meshcore_hub/common/config.py` — Remove `web_admin_enabled` field from `WebSettings` (lines 283-287) +- [ ] **1.4** `src/meshcore_hub/common/config.py` — Add OIDC settings block to `WebSettings` after `api_key` field (after line 294): `oidc_enabled`, `oidc_client_id`, `oidc_client_secret`, `oidc_discovery_url`, `oidc_redirect_uri`, `oidc_scopes`, `oidc_roles_claim`, `oidc_admin_role`, `oidc_member_role`, `oidc_session_secret`, `oidc_session_max_age`, `oidc_cookie_secure` + +## Phase 2: OIDC Module + +- [ ] **2.1** Create `src/meshcore_hub/web/oidc.py` — New file with `OAuth` registry, `init_oidc()`, `validate_discovery()`, `get_session_user()`, `get_user_roles()`, `strip_userinfo()` functions per plan Phase 2.1 + +## Phase 3: Web App Changes + +- [ ] **3.1** `src/meshcore_hub/web/app.py` — Add `SessionMiddleware` import (from `starlette.middleware.sessions`) +- [ ] **3.2** `src/meshcore_hub/web/app.py` — In `create_app()`: remove `admin_enabled` parameter (line 181), remove `effective_admin` computation (lines 231-233), remove `app.state.admin_enabled = effective_admin` (line 253) +- [ ] **3.3** `src/meshcore_hub/web/app.py` — In `create_app()`: add OIDC state initialization — set `app.state.oidc_enabled` from settings, conditionally add `SessionMiddleware`, call `init_oidc()` when enabled +- [ ] **3.4** `src/meshcore_hub/web/app.py` — In `lifespan()`: add eager OIDC discovery validation after HTTP client creation (after line 94) +- [ ] **3.5** `src/meshcore_hub/web/app.py` — In `_build_config_json()`: replace `"admin_enabled"` key (line 156) with `oidc_enabled`, `user`, `is_member`, `is_admin` based on OIDC state and session +- [ ] **3.6** `src/meshcore_hub/web/app.py` — Add four auth route endpoints before SPA catch-all: `/auth/login` (GET), `/auth/callback` (GET), `/auth/logout` (GET), `/auth/user` (GET) +- [ ] **3.7** `src/meshcore_hub/web/app.py` — In `api_proxy()`: add OIDC write gating — block POST/PUT/DELETE/PATCH for non-admin sessions when `oidc_enabled` (after line 346) +- [ ] **3.8** `src/meshcore_hub/web/app.py` — In `spa_catchall()`: replace `admin_enabled` template context (line 684) with `oidc_enabled`; add admin route protection (302 redirect to `/auth/login` for `/a` paths when OIDC enabled and no session) +- [ ] **3.9** `src/meshcore_hub/web/app.py` — Update `create_app()` docstring to remove `admin_enabled` param and add OIDC notes + +## Phase 4: CLI + +- [ ] **4.1** `src/meshcore_hub/web/cli.py` — Remove `admin_enabled` usage from `create_app()` call in production path (line 207-219) +- [ ] **4.2** `src/meshcore_hub/web/cli.py` — Add OIDC status line to startup banner (after line 185): show `OIDC: enabled` or `OIDC: disabled` + +## Phase 5: Frontend — Templates & Components + +- [ ] **5.1** `src/meshcore_hub/web/templates/spa.html` — Replace `{% if admin_enabled %}` conditional in footer (line 169) with `{% if oidc_enabled %}`; admin link requires `is_admin` session check +- [ ] **5.2** `src/meshcore_hub/web/templates/spa.html` — Add `
` placeholder in `navbar-end` before the loading spinner (before line 128), wrapped in `{% if oidc_enabled %}` +- [ ] **5.3** `src/meshcore_hub/web/static/js/spa/components.js` — Add `renderAuthSection(container, config)` function: renders login button or user dropdown based on `config.user` +- [ ] **5.4** `src/meshcore_hub/web/static/js/spa/app.js` — Call `renderAuthSection()` after config load (after line 34) +- [ ] **5.5** `src/meshcore_hub/web/static/js/spa/app.js` — Replace `config.admin_enabled` checks with `config.is_admin` for admin route gating (lines 91-94); remove admin routes registration when OIDC is enabled and user is not admin +- [ ] **5.6** `src/meshcore_hub/web/static/js/spa/api.js` — Add 401/403 response interceptor to `apiPost`, `apiPut`, `apiDelete`: detect auth errors and redirect to `/auth/login` +- [ ] **5.7** `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` — Replace `!config.admin_enabled` check (line 8) with `!config.is_admin` when `config.oidc_enabled`, show login redirect instead of enable hint +- [ ] **5.8** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Replace `!config.admin_enabled` check (line 13) with `!config.is_admin` when `config.oidc_enabled` +- [ ] **5.9** `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` — Replace `!config.admin_enabled` check (line 12) with `!config.is_admin` when `config.oidc_enabled` +- [ ] **5.10** `src/meshcore_hub/web/static/js/spa/pages/node-detail.js` — Replace `config.admin_enabled` check (line 129) with `config.is_admin` when `config.oidc_enabled` + +## Phase 6: i18n + +- [ ] **6.1** `src/meshcore_hub/web/static/locales/en.json` — Add `auth` section with keys: `login`, `logout`, `login_required`, `admin_required`, `login_hint`, `logged_in_as`, `session_expired`, `role_admin`, `role_member` +- [ ] **6.2** `src/meshcore_hub/web/static/locales/en.json` — Update `admin.admin_not_enabled` and `admin.admin_enable_hint` to reflect OIDC flow instead of `WEB_ADMIN_ENABLED` +- [ ] **6.3** `docs/i18n.md` — Document new `auth.*` translation keys and updated `admin.*` keys + +## Phase 7: Tests + +- [ ] **7.1** `tests/test_web/conftest.py` — Replace `admin_enabled=True/False` params in `web_app` and test fixtures with OIDC-aware equivalents +- [ ] **7.2** `tests/test_web/conftest.py` — Add OIDC test fixtures: `web_app_with_oidc`, `client_with_oidc_admin_session`, `client_with_oidc_member_session`, `client_with_oidc_no_session` +- [ ] **7.3** Create `tests/test_web/test_oidc.py` — Test OIDC settings validation (missing required fields when enabled) +- [ ] **7.4** `tests/test_web/test_oidc.py` — Test `/auth/login` redirects to IdP with correct parameters and stores `next` URL in session +- [ ] **7.5** `tests/test_web/test_oidc.py` — Test `/auth/callback` exchanges code, stores stripped userinfo in session, redirects to `next` URL +- [ ] **7.6** `tests/test_web/test_oidc.py` — Test `/auth/logout` clears session and redirects to IdP end_session_endpoint +- [ ] **7.7** `tests/test_web/test_oidc.py` — Test `/auth/user` returns current user JSON or 401 when not logged in +- [ ] **7.8** `tests/test_web/test_oidc.py` — Test admin route protection: no session → 302, member session → SPA shell (client-denied), admin session → 200 +- [ ] **7.9** `tests/test_web/test_oidc.py` — Test API proxy write gating: non-admin session → 403, admin session → proxied +- [ ] **7.10** `tests/test_web/test_oidc.py` — Test backward compatibility: OIDC disabled = current behavior (admin_enabled controls admin UI) +- [ ] **7.11** `tests/test_web/test_oidc.py` — Test config injection: `oidc_enabled`, `user`, `is_member`, `is_admin` values in SPA config +- [ ] **7.12** `tests/test_web/test_admin.py` — Replace `admin_enabled=True/False` fixture params (lines 25, 44) with OIDC fixtures +- [ ] **7.13** `tests/test_web/test_admin.py` — Update `test_admin_home_config_admin_enabled` (line 82) to check OIDC config keys instead +- [ ] **7.14** `tests/test_web/test_admin.py` — Update `TestAdminFooterLink` tests (lines 135-149) for OIDC-aware admin link visibility +- [ ] **7.15** Run targeted tests: `pytest tests/test_web/ -v` +- [ ] **7.16** Run quality checks: `pre-commit run --all-files` + +## Phase 8: Documentation + +- [ ] **8.1** `.env.example` — Remove `WEB_ADMIN_ENABLED` section (lines 338-340); add OIDC section with all 12 environment variables +- [ ] **8.2** `README.md` — Remove `WEB_ADMIN_ENABLED` row from env vars table (line 372); add OIDC env vars rows +- [ ] **8.3** `docs/upgrading.md` — Add new version section documenting migration from `WEB_ADMIN_ENABLED` to `OIDC_ENABLED` +- [ ] **8.4** `AGENTS.md` — Remove `WEB_ADMIN_ENABLED` from env vars table (line 640); add OIDC env vars; update admin UI notes (line 693) +- [ ] **8.5** `docs/i18n.md` — Update `admin_enable_hint` description to reference OIDC instead of `WEB_ADMIN_ENABLED` (line 342) +- [ ] **8.6** `.agents/skills/docs-sync/references/docker-source-guide.md` — Replace `WEB_ADMIN_ENABLED` row (line 196) with OIDC env vars + +--- + +## File Change Summary + +| # | File | Action | Phase(s) | +|---|------|--------|----------| +| 1 | `pyproject.toml` | Modify | 1 | +| 2 | `src/meshcore_hub/common/config.py` | Modify | 1 | +| 3 | `src/meshcore_hub/web/oidc.py` | Create | 2 | +| 4 | `src/meshcore_hub/web/app.py` | Modify | 3 | +| 5 | `src/meshcore_hub/web/cli.py` | Modify | 4 | +| 6 | `src/meshcore_hub/web/templates/spa.html` | Modify | 5 | +| 7 | `src/meshcore_hub/web/static/js/spa/components.js` | Modify | 5 | +| 8 | `src/meshcore_hub/web/static/js/spa/app.js` | Modify | 5 | +| 9 | `src/meshcore_hub/web/static/js/spa/api.js` | Modify | 5 | +| 10 | `src/meshcore_hub/web/static/js/spa/pages/admin/index.js` | Modify | 5 | +| 11 | `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` | Modify | 5 | +| 12 | `src/meshcore_hub/web/static/js/spa/pages/admin/members.js` | Modify | 5 | +| 13 | `src/meshcore_hub/web/static/js/spa/pages/node-detail.js` | Modify | 5 | +| 14 | `src/meshcore_hub/web/static/locales/en.json` | Modify | 6 | +| 15 | `docs/i18n.md` | Modify | 6, 8 | +| 16 | `tests/test_web/conftest.py` | Modify | 7 | +| 17 | `tests/test_web/test_oidc.py` | Create | 7 | +| 18 | `tests/test_web/test_admin.py` | Modify | 7 | +| 19 | `.env.example` | Modify | 8 | +| 20 | `README.md` | Modify | 8 | +| 21 | `docs/upgrading.md` | Modify | 8 | +| 22 | `AGENTS.md` | Modify | 8 | +| 23 | `.agents/skills/docs-sync/references/docker-source-guide.md` | Modify | 8 | diff --git a/docs/upgrading.md b/docs/upgrading.md index 3def1b8..97a8141 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -2,6 +2,44 @@ This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading. +## v0.10.0 + +This release includes **breaking changes** to the admin authentication model. + +### Overview of Changes + +| Area | Before | After | +|------|--------|-------| +| Admin auth | `WEB_ADMIN_ENABLED=true` (open access) | OIDC/OAuth2 authentication via identity provider | +| Auth library | None | Authlib (`authlib>=1.3.0`) | +| Admin access | Anyone with the URL | Authenticated users with `admin` role from IdP | +| Session mgmt | None | Starlette `SessionMiddleware` (signed cookies) | +| Write gating | None (API proxy open) | POST/PUT/DELETE/PATCH require admin session when OIDC enabled | + +### Migration Steps + +1. **Set up an OIDC identity provider** (LogTo, Keycloak, etc.) +2. **Configure OIDC environment variables** in your `.env`: + ```bash + OIDC_ENABLED=true + OIDC_CLIENT_ID=your-client-id + OIDC_CLIENT_SECRET=your-client-secret + OIDC_DISCOVERY_URL=https://your-idp.example.com/.well-known/openid-configuration + OIDC_SESSION_SECRET=$(openssl rand -hex 32) + ``` +3. **Remove `WEB_ADMIN_ENABLED`** from your `.env` (no longer used) +4. **Configure roles** in your IdP: assign `admin` and/or `member` roles to users + +### Removed Variables + +| Variable | Reason | +|----------|--------| +| `WEB_ADMIN_ENABLED` | Replaced by `OIDC_ENABLED` | + +### New Variables + +See the OIDC section in `.env.example` for the full list of 12 new environment variables. + ## v0.9.0 This release includes **breaking changes** to the MQTT broker, packet capture service, data ingestion pipeline, and public key handling. diff --git a/pyproject.toml b/pyproject.toml index e396d50..03fc5c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ dependencies = [ "jinja2>=3.1.0", "python-multipart>=0.0.6", "httpx>=0.25.0", + "authlib>=1.3.0", + "itsdangerous>=2.0.0", "aiosqlite>=0.19.0", "pyyaml>=6.0.0", "python-frontmatter>=1.0.0", @@ -119,6 +121,7 @@ module = [ "markdown.*", "prometheus_client.*", "meshcoredecoder.*", + "authlib.*", ] ignore_missing_imports = true diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index 359bdd3..6f4dd90 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -280,10 +280,39 @@ class WebSettings(CommonSettings): ge=0, ) - # Admin interface (disabled by default for security) - web_admin_enabled: bool = Field( - default=False, - description="Enable admin interface at /a/", + # OIDC / OAuth2 authentication + oidc_enabled: bool = Field(default=False, description="Enable OIDC authentication") + oidc_client_id: Optional[str] = Field(default=None, description="OIDC client ID") + oidc_client_secret: Optional[str] = Field( + default=None, description="OIDC client secret" + ) + oidc_discovery_url: Optional[str] = Field( + default=None, description="OIDC discovery URL" + ) + oidc_redirect_uri: Optional[str] = Field( + default=None, + description="OIDC callback URL (overrides auto-derivation)", + ) + oidc_scopes: str = Field( + default="openid email profile", description="OAuth scopes to request" + ) + oidc_roles_claim: str = Field( + default="roles", description="ID token claim containing user roles" + ) + oidc_admin_role: str = Field( + default="admin", description="Role value granting admin access" + ) + oidc_member_role: str = Field( + default="member", description="Role value granting member access" + ) + oidc_session_secret: Optional[str] = Field( + default=None, description="Secret key for signing session cookies" + ) + oidc_session_max_age: int = Field( + default=86400, description="Session cookie lifetime in seconds" + ) + oidc_cookie_secure: bool = Field( + default=False, description="HTTPS-only session cookies (enable in production)" ) # API connection diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 6d398bc..471501c 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -12,15 +12,25 @@ from zoneinfo import ZoneInfo import httpx from fastapi import FastAPI, Request, Response -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.sessions import SessionMiddleware from meshcore_hub import __version__ from meshcore_hub.collector.letsmesh_decoder import LetsMeshPacketDecoder from meshcore_hub.common.i18n import load_locale, t from meshcore_hub.common.schemas import RadioConfig from meshcore_hub.web.middleware import CacheControlMiddleware +from meshcore_hub.web.oidc import ( + get_session_user, + get_user_roles, + init_oidc, + oauth, + strip_userinfo, + validate_discovery, +) from meshcore_hub.web.pages import PageLoader logger = logging.getLogger(__name__) @@ -92,6 +102,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: timeout=30.0, ) + if getattr(app.state, "oidc_enabled", False): + ok = await validate_discovery() + if not ok: + logger.warning( + "OIDC discovery failed — login will not work until IdP is reachable" + ) + logger.info(f"Web dashboard started, API URL: {api_url}") yield @@ -153,7 +170,6 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "network_contact_github": app.state.network_contact_github, "network_contact_youtube": app.state.network_contact_youtube, "network_welcome_text": app.state.network_welcome_text, - "admin_enabled": app.state.admin_enabled, "features": features, "custom_pages": custom_pages, "logo_url": app.state.logo_url, @@ -168,6 +184,28 @@ def _build_config_json(app: FastAPI, request: Request) -> str: "logo_invert_light": app.state.logo_invert_light, } + if getattr(app.state, "oidc_enabled", False): + user = get_session_user(request) + is_member, is_admin = get_user_roles( + request, + app.state.oidc_roles_claim, + app.state.oidc_admin_role, + app.state.oidc_member_role, + ) + config.update( + oidc_enabled=True, + user=user, + is_member=is_member, + is_admin=is_admin, + ) + else: + config.update( + oidc_enabled=False, + user=None, + is_member=False, + is_admin=False, + ) + # Escape "" sequences to prevent XSS breakout from the #