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..bfc2c85 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ # # Serial ports are typically /dev/ttyUSB[0-9] or /dev/ttyACM[0-9] on Linux. # ----------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # ============================================================================= # COMMON SETTINGS @@ -335,9 +336,63 @@ 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 base URL (.well-known/openid-configuration is appended automatically) +# OIDC_DISCOVERY_URL= + +# OIDC callback URL (overrides auto-derivation from request) +# Example: https://hub.example.com/auth/callback +# OIDC_REDIRECT_URI= + +# Post-logout redirect URI (must match Sign-out redirect URIs configured in IdP) +# Falls back to OIDC_REDIRECT_URI base or request.base_url if not set +# Example: https://hub.example.com/ +# OIDC_POST_LOGOUT_REDIRECT_URI= + +# OAuth scopes to request. The 'openid' scope is required for ID tokens +# and userinfo endpoint access. Quotes around the value are stripped automatically. +# 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..6c67934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This document provides context and guidelines for AI coding assistants working o * You MUST install all project dependencies using `pip install -e ".[dev]"` command` * You MUST install `pre-commit` for quality checks * **Never `git push` without explicit confirmation** — staging and committing after discrete changes is fine, but pushing to remote requires the user to explicitly request it -* You MUST keep project documentation in sync with behavior/config/schema changes made in code (at minimum update relevant sections in `README.md`, `SCHEMAS.md`, `docs/upgrading.md`, `docs/letsmesh.md` when applicable) +* You MUST keep project documentation in sync with behavior/config/schema changes made in code (at minimum update relevant sections in `README.md`, `SCHEMAS.md`, `docs/upgrading.md`, `docs/auth.md`, `docs/letsmesh.md` when applicable) * Before commiting: - Run **targeted tests** for the components you changed, not the full suite: - `pytest tests/test_web/` for web-only changes (templates, static JS, web routes) @@ -39,6 +39,7 @@ MeshCore Hub is a Python 3.14+ monorepo for managing and orchestrating MeshCore - [SCHEMAS.md](SCHEMAS.md) - MeshCore event JSON schemas and database mappings - [docs/upgrading.md](docs/upgrading.md) - Upgrade guide for breaking changes +- [docs/auth.md](docs/auth.md) - OIDC authentication setup and configuration - [docs/letsmesh.md](docs/letsmesh.md) - LetsMesh packet decoding details - [docs/seeding.md](docs/seeding.md) - Seed data format and import guide - [docs/i18n.md](docs/i18n.md) - Translation reference guide @@ -637,7 +638,19 @@ 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_POST_LOGOUT_REDIRECT_URI` - Post-logout redirect URI (must match IdP sign-out URIs, falls back to `OIDC_REDIRECT_URI` base) +- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically. When using LogTo as the OIDC provider, include `roles` in `OIDC_SCOPES` (e.g., `"openid email profile roles"`) to enable role-based admin access. +- `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 +703,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..35b7cf2 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,10 @@ All components are configured via environment variables. Create a `.env` file or For details on how the collector normalizes and decodes LetsMesh packets, see [docs/letsmesh.md](docs/letsmesh.md). +### OIDC Authentication + +The web dashboard supports OIDC/OAuth2 authentication. When enabled (`OIDC_ENABLED=true`), the admin interface requires users to authenticate with an identity provider (e.g. LogTo, Keycloak) and have the `admin` role assigned. See [docs/auth.md](docs/auth.md) for setup instructions, configuration reference, and IdP-specific guides. + ### Webhooks The collector can forward events (advertisements, messages) to external HTTP endpoints via webhooks with configurable URLs, secrets, retries, and timeouts. See [docs/webhooks.md](docs/webhooks.md) for the full configuration reference and payload format. @@ -369,7 +373,19 @@ 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 base URL — `.well-known/openid-configuration` is appended automatically (required when OIDC_ENABLED=true) | +| `OIDC_REDIRECT_URI` | _(auto-derived)_ | Explicit callback URL (overrides auto-derivation from request) | +| `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto-derived)_ | Post-logout redirect URI (must match Sign-out redirect URIs in IdP). Falls back to `OIDC_REDIRECT_URI` base or `request.base_url` | +| `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required for ID tokens. Quotes are stripped automatically. | +| `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 | @@ -437,6 +453,8 @@ curl -H "Authorization: Bearer " http://localhost:8000/api/v1/node curl -H "Authorization: Bearer " http://localhost:8000/api/v1/members ``` +The web dashboard supports OIDC/OAuth2 authentication for admin access. When enabled, users must authenticate with an identity provider and have the `admin` role assigned. See [docs/auth.md](docs/auth.md) for setup instructions and IdP-specific guides. + ### Example Endpoints | Method | Endpoint | Description | @@ -585,6 +603,7 @@ meshcore-hub/ - [docs/seeding.md](docs/seeding.md) - Seed data format and import guide - [docs/i18n.md](docs/i18n.md) - Translation reference guide - [docs/content.md](docs/content.md) - Custom content setup guide +- [docs/auth.md](docs/auth.md) - OIDC authentication setup and configuration - [docs/webhooks.md](docs/webhooks.md) - Webhook configuration reference - [AGENTS.md](AGENTS.md) - Guidelines for AI coding assistants 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/auth.md b/docs/auth.md new file mode 100644 index 0000000..10c379b --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,82 @@ +# OIDC Authentication + +MeshCore Hub supports OpenID Connect (OIDC) for authenticating web dashboard users. When enabled, the admin interface (`/admin/`) requires an authenticated session with the `admin` role. + +When OIDC is **disabled** (the default), no admin functionality is exposed — the admin link is hidden from the navbar and admin routes are not registered. + +## Architecture + +OIDC is implemented entirely in the web dashboard layer. The REST API uses static Bearer token authentication independently of OIDC. The web dashboard proxies API requests and gates write operations (POST, PUT, DELETE, PATCH) by checking the user's session role. + +``` +Browser → Web Dashboard (OIDC session + API proxy) → REST API (Bearer token) +``` + +## Login Flow + +1. User visits `/admin/` — no session exists — server redirects to `/auth/login?next=/admin/` +2. `/auth/login` stores the `next` URL in the session and redirects to the IdP +3. User authenticates at the IdP, which redirects back to `/auth/callback?code=...` +4. `/auth/callback` exchanges the authorization code for tokens, extracts userinfo and roles, stores them in a session cookie, and redirects to the `next` URL +5. The SPA receives `window.__APP_CONFIG__` with `oidc_enabled`, `user`, `is_member`, and `is_admin` flags +6. Admin routes are registered client-side only when `is_admin` is `true` +7. Write operations through the API proxy are blocked for non-admin sessions (HTTP 403) +8. Logout (`/auth/logout`) clears the session and redirects to the IdP's end-session endpoint + +## Configuration + +All OIDC settings are environment variables. Set `OIDC_ENABLED=true` to activate. + +| Variable | Default | Description | +|----------|---------|-------------| +| `OIDC_ENABLED` | `false` | Enable OIDC authentication | +| `OIDC_CLIENT_ID` | _(none)_ | OAuth2 client ID from your IdP | +| `OIDC_CLIENT_SECRET` | _(none)_ | OAuth2 client secret from your IdP | +| `OIDC_DISCOVERY_URL` | _(none)_ | IdP base URL — `.well-known/openid-configuration` is appended automatically (e.g. `https://auth.example.com/oidc`) | +| `OIDC_REDIRECT_URI` | _(auto)_ | Override callback URL (auto-derived from request if not set) | +| `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto)_ | Post-logout redirect URI (falls back to `OIDC_REDIRECT_URI` base or request URL) | +| `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required. Quotes are stripped automatically. | +| `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles | +| `OIDC_ADMIN_ROLE` | `admin` | Role value that grants admin access | +| `OIDC_MEMBER_ROLE` | `member` | Role value that grants member access | +| `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (generate with `openssl rand -hex 32`) | +| `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default: 24 hours) | +| `OIDC_COOKIE_SECURE` | `false` | Set to `true` to require HTTPS for session cookies | + +## Local Development (No HTTPS) + +To test OIDC locally without TLS: + +- Set `OIDC_REDIRECT_URI=http://localhost:8080/auth/callback` explicitly — auto-derivation may produce an incorrect URL depending on your setup +- Keep `OIDC_COOKIE_SECURE=false` (the default) — cookies won't be restricted to HTTPS +- Register `http://localhost:8080/auth/callback` as a redirect URI in your IdP +- Register `http://localhost:8080/` as a post-logout redirect URI in your IdP +- `OIDC_SESSION_SECRET` is still required — generate one even for local testing + +## IdP Provider Guides + +### LogTo + +[LogTo](https://logto.io/) is an open-source identity provider that works well with MeshCore Hub. + +**Setup:** + +1. Create a new **Traditional Web** application in LogTo +2. Set the redirect URI to `https://your-hub-domain/auth/callback` +3. Set the post-logout redirect URI to `https://your-hub-domain/` +4. Copy the client ID and client secret into `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` +5. Set `OIDC_DISCOVERY_URL` to your LogTo endpoint (e.g. `https://auth.example.com/oidc`) + +**Required scope changes:** + +LogTo returns roles via the `roles` claim in the ID token. You must include `roles` in the scopes: + +```bash +OIDC_SCOPES="openid email profile roles" +``` + +`OIDC_ROLES_CLAIM` defaults to `roles`, which matches LogTo's claim name — no change needed. + +**Role assignment:** + +Create `admin` and/or `member` roles in LogTo and assign them to users. The role names must match `OIDC_ADMIN_ROLE` and `OIDC_MEMBER_ROLE` (both default to `admin` and `member` respectively). 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..d11a30a --- /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 /admin/node-tags | | + |------------------------------>| | + | | No session | + | 302 /auth/login?next=/admin/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 → /admin/node-tags | | + |<------------------------------| | + | | | + | GET /admin/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..1dd2dd5 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -2,6 +2,46 @@ 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 environment variables. + +**Important for LogTo users:** You must pass `client_id` in the logout request for the post-logout redirect to work. This is handled automatically by the application. You also need to register your app's URL as a **Sign-out redirect URI** in the LogTo admin console (e.g. `https://ipnt.uk`). If the redirect still doesn't work after updating, set `OIDC_POST_LOGOUT_REDIRECT_URI` explicitly to match your registered URI. + ## 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..f6160ab 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -280,10 +280,46 @@ 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_post_logout_redirect_uri: Optional[str] = Field( + default=None, + description=( + "OIDC post-logout redirect URI (must match Sign-out redirect URIs " + "in IdP config). Falls back to OIDC_REDIRECT_URI base or request.base_url." + ), + ) + 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..eea1641 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 #