Merge pull request #178 from ipnet-mesh/feature/oidc-oauth-support

Add OIDC/OAuth2 authentication for web dashboard
This commit is contained in:
JingleManSweep
2026-04-29 12:57:58 +01:00
committed by GitHub
30 changed files with 2319 additions and 142 deletions
@@ -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 |
+57 -2
View File
@@ -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)
+16 -3
View File
@@ -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
+20 -1
View File
@@ -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 <API_READ_KEY>" http://localhost:8000/api/v1/node
curl -H "Authorization: Bearer <API_ADMIN_KEY>" 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
+13 -1
View File
@@ -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:-}
+82
View File
@@ -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).
+18 -2
View File
@@ -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 <code>WEB_ADMIN_ENABLED=true</code> to enable admin features. | Configuration hint (HTML allowed) |
| `admin_enable_hint` | Set <code>OIDC_ENABLED=true</code> 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:
@@ -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 %}
<div id="auth-section"></div>
{% 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/`
@@ -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 `<div id="auth-section"></div>` 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 |
+40
View File
@@ -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.
+3
View File
@@ -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
+40 -4
View File
@@ -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
+279 -14
View File
@@ -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 "</script>" sequences to prevent XSS breakout from the
# <script> block where this JSON is embedded via |safe in the
# Jinja2 template. "<\/" is valid JSON per the spec and parsed
@@ -178,7 +216,6 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
def create_app(
api_url: str | None = None,
api_key: str | None = None,
admin_enabled: bool | None = None,
network_name: str | None = None,
network_city: str | None = None,
network_country: str | None = None,
@@ -198,7 +235,6 @@ def create_app(
Args:
api_url: Base URL of the MeshCore Hub API
api_key: API key for authentication
admin_enabled: Enable admin interface at /a/
network_name: Display name for the network
network_city: City where the network is located
network_country: Country where the network is located
@@ -212,6 +248,11 @@ def create_app(
Returns:
Configured FastAPI application
When OIDC is enabled via environment variables (OIDC_ENABLED=true),
the app adds SessionMiddleware and registers auth routes (/auth/login,
/auth/callback, /auth/logout, /auth/user). Write methods through the
API proxy require admin session when OIDC is enabled.
"""
# Load settings from environment if not provided
from meshcore_hub.common.config import get_web_settings
@@ -227,14 +268,35 @@ def create_app(
redoc_url=None,
)
# Compute effective admin flag (parameter overrides setting)
effective_admin = (
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
)
# Add cache control headers based on resource type
app.add_middleware(CacheControlMiddleware)
# OIDC / session middleware
if settings.oidc_enabled:
app.add_middleware(
SessionMiddleware,
secret_key=settings.oidc_session_secret or "insecure-dev-secret",
session_cookie="meshcore-session",
max_age=settings.oidc_session_max_age,
same_site="lax",
https_only=settings.oidc_cookie_secure,
)
init_oidc(
client_id=settings.oidc_client_id or "",
client_secret=settings.oidc_client_secret or "",
discovery_url=settings.oidc_discovery_url or "",
scopes=settings.oidc_scopes,
)
app.state.oidc_enabled = True
app.state.oidc_client_id = settings.oidc_client_id
app.state.oidc_redirect_uri = settings.oidc_redirect_uri
app.state.oidc_post_logout_redirect_uri = settings.oidc_post_logout_redirect_uri
app.state.oidc_roles_claim = settings.oidc_roles_claim
app.state.oidc_admin_role = settings.oidc_admin_role
app.state.oidc_member_role = settings.oidc_member_role
else:
app.state.oidc_enabled = False
# Load i18n translations
app.state.web_locale = settings.web_locale or "en"
app.state.web_datetime_locale = settings.web_datetime_locale or "en-US"
@@ -250,7 +312,6 @@ def create_app(
)
app.state.api_url = api_url or settings.api_base_url
app.state.api_key = api_key or settings.api_key
app.state.admin_enabled = effective_admin
app.state.network_name = network_name or settings.network_name
app.state.network_city = network_city or settings.network_city
app.state.network_country = network_country or settings.network_country
@@ -298,6 +359,65 @@ def create_app(
templates.env.globals["t"] = t
app.state.templates = templates
# --- Error handlers ---
def _is_api_request(request: Request) -> bool:
return request.url.path.startswith("/api/")
def _render_error_html(
request: Request, status_code: int, message: str, detail: str = ""
) -> Response:
tmpl: Jinja2Templates = request.app.state.templates
return tmpl.TemplateResponse(
request,
"error.html",
{
"status_code": status_code,
"message": message,
"detail": detail,
"theme": getattr(request.app.state, "web_theme", "dark"),
"network_name": getattr(
request.app.state, "network_name", "MeshCore Hub"
),
"version": __version__,
},
status_code=status_code,
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
) -> Response:
if _is_api_request(request):
return JSONResponse(
{"detail": exc.detail},
status_code=exc.status_code,
)
message_map = {
404: "Page not found",
405: "Method not allowed",
}
return _render_error_html(
request,
exc.status_code,
message_map.get(exc.status_code, "Something went wrong"),
str(exc.detail) if exc.detail else "",
)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> Response:
logger.exception("Unhandled exception on %s: %s", request.url.path, exc)
if _is_api_request(request):
return JSONResponse(
{"detail": "Internal server error"},
status_code=500,
)
return _render_error_html(
request,
500,
"Internal server error",
"",
)
# Compute timezone
app.state.timezone = settings.tz
try:
@@ -330,11 +450,30 @@ def create_app(
# --- API Proxy ---
@app.api_route(
"/api/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
tags=["API Proxy"],
)
async def api_proxy(request: Request, path: str) -> Response:
"""Proxy API requests to the backend API server."""
# OIDC write gating: block non-admin sessions
if request.app.state.oidc_enabled and request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
):
_, is_admin = get_user_roles(
request,
request.app.state.oidc_roles_claim,
request.app.state.oidc_admin_role,
request.app.state.oidc_member_role,
)
if not is_admin:
return JSONResponse(
{"detail": "Admin access required", "code": "AUTH_REQUIRED"},
status_code=403,
)
client: httpx.AsyncClient = request.app.state.http_client
url = f"/api/{path}"
@@ -656,10 +795,136 @@ def create_app(
return Response(content=xml, media_type="application/xml")
# --- Auth Routes (OIDC) ---
@app.get("/auth/login", tags=["Auth"])
async def auth_login(request: Request) -> Response:
"""Initiate OIDC login flow."""
if not request.app.state.oidc_enabled:
return JSONResponse({"detail": "OIDC not enabled"}, status_code=400)
next_url = request.query_params.get("next", "/")
request.session["next"] = next_url
redirect_uri = getattr(request.app.state, "oidc_redirect_uri", None) or str(
request.url_for("auth_callback")
)
response: Response = await oauth.oidc.authorize_redirect(request, redirect_uri)
logger.info(
"OIDC login: authorization URL=%s",
response.headers.get("location", "unknown"),
)
return response
@app.get("/auth/callback", tags=["Auth"], name="auth_callback")
async def auth_callback(request: Request) -> Response:
"""Handle OIDC callback and store user in session."""
if not request.app.state.oidc_enabled:
return JSONResponse({"detail": "OIDC not enabled"}, status_code=400)
token = await oauth.oidc.authorize_access_token(request)
logger.info(
"OIDC callback: token keys=%s, granted scope=%s",
list(token.keys()),
token.get("scope"),
)
userinfo = token.get("userinfo") or {}
logger.info("OIDC callback: ID token userinfo=%s", dict(userinfo))
if not userinfo.get("name") and not userinfo.get("email"):
try:
userinfo = await oauth.oidc.userinfo(token=token)
logger.info("OIDC callback: /userinfo endpoint=%s", dict(userinfo))
except Exception:
logger.exception(
"OIDC userinfo fetch failed, using ID token claims only"
)
roles_claim = request.app.state.oidc_roles_claim
session_user = strip_userinfo(userinfo, roles_claim)
logger.info("OIDC callback: session user=%s", session_user)
request.session["user"] = session_user
request.session["id_token"] = token.get("id_token")
next_url = request.session.pop("next", "/")
from starlette.responses import RedirectResponse
return RedirectResponse(url=next_url)
@app.get("/auth/logout", tags=["Auth"])
async def auth_logout(request: Request) -> Response:
"""Clear session and redirect to IdP end_session_endpoint."""
if not request.app.state.oidc_enabled:
return JSONResponse({"detail": "OIDC not enabled"}, status_code=400)
from starlette.responses import RedirectResponse
id_token_hint = request.session.get("id_token")
client_id = request.app.state.oidc_client_id
post_logout_uri = request.app.state.oidc_post_logout_redirect_uri
if not post_logout_uri:
redirect_uri = getattr(request.app.state, "oidc_redirect_uri", None)
if redirect_uri:
base = redirect_uri.rsplit("/auth/callback", 1)[0]
post_logout_uri = base.rstrip("/") + "/"
else:
post_logout_uri = str(request.base_url).rstrip("/")
logger.info(
"OIDC logout: client_id=%s, id_token_hint=%s, post_logout_redirect_uri=%s",
client_id,
"present" if id_token_hint else "MISSING",
post_logout_uri,
)
try:
response: Response = await oauth.oidc.logout_redirect(
request,
post_logout_redirect_uri=post_logout_uri,
id_token_hint=id_token_hint,
client_id=client_id,
)
except Exception:
logger.exception("OIDC logout_redirect failed, redirecting to /")
response = RedirectResponse(url="/")
request.session.clear()
return response
@app.get("/auth/user", tags=["Auth"])
async def auth_user(request: Request) -> JSONResponse:
"""Return current user JSON or 401 when not logged in."""
if not request.app.state.oidc_enabled:
return JSONResponse({"detail": "OIDC not enabled"}, status_code=400)
user = get_session_user(request)
if not user:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
is_member, is_admin = get_user_roles(
request,
request.app.state.oidc_roles_claim,
request.app.state.oidc_admin_role,
request.app.state.oidc_member_role,
)
return JSONResponse(
{"user": user, "is_member": is_member, "is_admin": is_admin}
)
# --- SPA Catch-All (MUST be last) ---
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"])
async def spa_catchall(request: Request, path: str = "") -> HTMLResponse:
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None)
async def spa_catchall(request: Request, path: str = "") -> Response:
"""Serve the SPA shell for all non-API routes."""
# Admin route protection when OIDC is enabled
if path.startswith("admin") and (
path == "admin" or path == "admin/" or path.startswith("admin/")
):
if request.app.state.oidc_enabled:
user = get_session_user(request)
if not user:
from starlette.responses import RedirectResponse
return RedirectResponse(url=f"/auth/login?next=/{path}")
logger.debug(
"Admin route access: path=%s, user=%s",
path,
user.get("name"),
)
templates_inst: Jinja2Templates = request.app.state.templates
features = request.app.state.features
page_loader = request.app.state.page_loader
@@ -681,7 +946,7 @@ def create_app(
"network_contact_github": request.app.state.network_contact_github,
"network_contact_youtube": request.app.state.network_contact_youtube,
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
"oidc_enabled": request.app.state.oidc_enabled,
"features": features,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
+2
View File
@@ -183,6 +183,8 @@ def web(
if effective_city and effective_country:
click.echo(f"Location: {effective_city}, {effective_country}")
click.echo(f"Reload mode: {reload}")
oidc_status = "enabled" if settings.oidc_enabled else "disabled"
click.echo(f"OIDC: {oidc_status}")
disabled_features = [
name for name, enabled in settings.features.items() if not enabled
]
+81
View File
@@ -0,0 +1,81 @@
"""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."""
if not discovery_url.endswith("/.well-known/openid-configuration"):
discovery_url = discovery_url.rstrip("/") + "/.well-known/openid-configuration"
scope_list = scopes.strip('"').strip("'").split()
oauth.register(
name="oidc",
client_id=client_id,
client_secret=client_secret,
server_metadata_url=discovery_url,
client_kwargs={"scope": scope_list},
)
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: Any = user.get(roles_claim, [])
if isinstance(roles, str):
roles = [roles]
is_admin = admin_role in roles
is_member = member_role in roles
logger.info(
"OIDC roles check: roles_claim=%s, raw_roles=%s, is_member=%s, is_admin=%s",
roles_claim,
roles,
is_member,
is_admin,
)
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."""
name = (
userinfo.get("name")
or userinfo.get("preferred_username")
or userinfo.get("username")
or userinfo.get("nickname")
)
return {
"sub": userinfo.get("sub"),
"name": name,
"email": userinfo.get("email"),
"picture": userinfo.get("picture"),
roles_claim: userinfo.get(roles_claim, []),
}
+15
View File
@@ -24,6 +24,18 @@ export async function apiGet(path, params = {}) {
return response.json();
}
/**
* Check response for auth errors and redirect to login if needed.
* @param {Response} response
*/
function checkAuthResponse(response) {
const config = window.__APP_CONFIG__ || {};
if (config.oidc_enabled && (response.status === 401 || response.status === 403)) {
const next = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/auth/login?next=${next}`;
}
}
/**
* Make a POST request with JSON body.
* @param {string} path - URL path
@@ -36,6 +48,7 @@ export async function apiPost(path, body) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
checkAuthResponse(response);
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
@@ -56,6 +69,7 @@ export async function apiPut(path, body) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
checkAuthResponse(response);
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
@@ -71,6 +85,7 @@ export async function apiPut(path, body) {
*/
export async function apiDelete(path) {
const response = await fetch(path, { method: 'DELETE' });
checkAuthResponse(response);
if (!response.ok) {
const text = await response.text();
throw new Error(`API error: ${response.status} - ${text}`);
+17 -10
View File
@@ -6,7 +6,7 @@
*/
import { Router } from './router.js';
import { getConfig } from './components.js';
import { getConfig, renderAuthSection } from './components.js';
import { loadLocale, t } from './i18n.js';
// Page modules (lazy-loaded)
@@ -87,11 +87,13 @@ if (features.pages !== false) {
router.addRoute('/pages/:slug', pageHandler(pages.customPage));
}
// Admin routes
router.addRoute('/a', pageHandler(pages.adminIndex));
router.addRoute('/a/', pageHandler(pages.adminIndex));
router.addRoute('/a/node-tags', pageHandler(pages.adminNodeTags));
router.addRoute('/a/members', pageHandler(pages.adminMembers));
// Admin routes (only register when OIDC disabled or user is admin)
if (!config.oidc_enabled || config.is_admin) {
router.addRoute('/admin', pageHandler(pages.adminIndex));
router.addRoute('/admin/', pageHandler(pages.adminIndex));
router.addRoute('/admin/node-tags', pageHandler(pages.adminNodeTags));
router.addRoute('/admin/members', pageHandler(pages.adminMembers));
}
// 404 handler
router.setNotFound(pageHandler(pages.notFound));
@@ -145,10 +147,10 @@ function updatePageTitle(pathname) {
const networkName = config.network_name || 'MeshCore Network';
const titles = {
'/': networkName,
'/a': composePageTitle('entities.admin'),
'/a/': composePageTitle('entities.admin'),
'/a/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
'/a/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
'/admin': composePageTitle('entities.admin'),
'/admin/': composePageTitle('entities.admin'),
'/admin/node-tags': `${t('entities.tags')} - ${t('entities.admin')} - ${networkName}`,
'/admin/members': `${t('entities.members')} - ${t('entities.admin')} - ${networkName}`,
};
// Add feature-dependent titles
@@ -180,4 +182,9 @@ router.onNavigate((pathname) => {
// Load locale then start the router
const locale = localStorage.getItem('meshcore-locale') || config.locale || 'en';
await loadLocale(locale);
// Render auth section in navbar (after translations are loaded)
const authSection = document.getElementById('auth-section');
renderAuthSection(authSection, config);
router.start();
@@ -570,3 +570,48 @@ export function submitOnEnter(e) {
e.target.closest('form').requestSubmit();
}
}
/**
* Render the auth section in the navbar.
* Shows a login button when not authenticated, or a user dropdown when logged in.
* @param {HTMLElement} container - The #auth-section element
* @param {Object} config - App configuration object
*/
export function renderAuthSection(container, config) {
if (!container) return;
if (!config.oidc_enabled) {
container.innerHTML = '';
return;
}
const user = config.user;
if (!user) {
container.innerHTML = `
<a href="/auth/login" class="btn btn-sm btn-outline">${t('auth.login')}</a>
`;
return;
}
const displayName = user.name || user.email || 'User';
const initials = displayName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const pictureHtml = user.picture
? `<img src="${user.picture}" alt="${displayName}" class="w-8 h-8 rounded-full" />`
: `<span class="text-sm font-bold">${initials}</span>`;
const roleBadge = config.is_admin
? `<span class="badge badge-primary badge-sm">${t('auth.role_admin')}</span>`
: config.is_member
? `<span class="badge badge-ghost badge-sm">${t('auth.role_member')}</span>`
: '';
container.innerHTML = `
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle btn-sm avatar">
${pictureHtml}
</div>
<ul tabindex="0" class="dropdown-content menu menu-sm z-[1] p-2 shadow bg-base-100 rounded-box w-52 mt-3">
<li class="menu-title"><span>${displayName}</span>${roleBadge}</li>
<li><a href="/auth/logout">${t('auth.logout')}</a></li>
</ul>
</div>
`;
}
@@ -5,13 +5,24 @@ export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
if (config.oidc_enabled ? !config.is_admin : false) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('auth.admin_required')}</p>
<p class="text-sm opacity-50 mt-2">${t('auth.login_hint')}</p>
<a href="/auth/login" class="btn btn-primary mt-6">${t('auth.login')}</a>
</div>`, container);
return;
}
if (!config.oidc_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<p class="text-sm opacity-50 mt-2">${unsafeHTML(t('admin.admin_enable_hint'))}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
</div>`, container);
return;
@@ -37,7 +48,7 @@ export async function render(container, params, router) {
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a href="/a/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<a href="/admin/members" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconUsers('h-6 w-6')}
@@ -46,7 +57,7 @@ export async function render(container, params, router) {
<p>${t('admin.members_description')}</p>
</div>
</a>
<a href="/a/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<a href="/admin/node-tags" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">
<h2 class="card-title">
${iconTag('h-6 w-6')}
@@ -9,13 +9,13 @@ export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
if (!config.is_admin && config.oidc_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
<p class="opacity-70">${t('auth.admin_required')}</p>
<a href="/auth/login" class="btn btn-primary mt-6">${t('auth.login')}</a>
</div>`, container);
return;
}
@@ -78,7 +78,7 @@ export async function render(container, params, router) {
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li><a href="/admin/">${t('entities.admin')}</a></li>
<li>${t('entities.members')}</li>
</ul>
</div>
@@ -211,15 +211,18 @@ ${flashHtml}
</dialog>`, container);
let activeDeleteId = '';
const ac = new AbortController();
const signal = ac.signal;
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
// Add Member
container.querySelector('#btn-add-member').addEventListener('click', () => {
on(container.querySelector('#btn-add-member'), 'click', () => {
const form = container.querySelector('#add-member-form');
form.reset();
container.querySelector('#addModal').showModal();
});
container.querySelector('#addCancel').addEventListener('click', () => {
on(container.querySelector('#addCancel'), 'click', () => {
container.querySelector('#addModal').close();
});
@@ -237,16 +240,16 @@ ${flashHtml}
try {
await apiPost('/api/v1/members', body);
container.querySelector('#addModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })));
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#addModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
// Edit Member
container.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', () => {
on(btn, 'click', () => {
const row = btn.closest('tr');
container.querySelector('#edit_id').value = row.dataset.memberId;
container.querySelector('#edit_member_id').value = row.dataset.memberMemberId;
@@ -258,7 +261,7 @@ ${flashHtml}
});
});
container.querySelector('#editCancel').addEventListener('click', () => {
on(container.querySelector('#editCancel'), 'click', () => {
container.querySelector('#editModal').close();
});
@@ -277,16 +280,16 @@ ${flashHtml}
try {
await apiPut('/api/v1/members/' + encodeURIComponent(id), body);
container.querySelector('#editModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })));
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#editModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
// Delete Member
container.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => {
on(btn, 'click', () => {
const row = btn.closest('tr');
activeDeleteId = row.dataset.memberId;
const memberName = row.dataset.memberName;
@@ -299,21 +302,23 @@ ${flashHtml}
});
});
container.querySelector('#deleteCancel').addEventListener('click', () => {
on(container.querySelector('#deleteCancel'), 'click', () => {
container.querySelector('#deleteModal').close();
});
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
on(container.querySelector('#deleteConfirm'), 'click', async () => {
try {
await apiDelete('/api/v1/members/' + encodeURIComponent(activeDeleteId));
container.querySelector('#deleteModal').close();
router.navigate('/a/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })));
router.navigate('/admin/members?message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.member') })), true);
} catch (err) {
container.querySelector('#deleteModal').close();
router.navigate('/a/members?error=' + encodeURIComponent(err.message));
router.navigate('/admin/members?error=' + encodeURIComponent(err.message), true);
}
});
return () => ac.abort();
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
@@ -10,13 +10,13 @@ export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.admin_enabled) {
if (!config.is_admin && config.oidc_enabled) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
<h1 class="text-3xl font-bold mb-2">${t('admin.access_denied')}</h1>
<p class="opacity-70">${t('admin.admin_not_enabled')}</p>
<a href="/" class="btn btn-primary mt-6">${t('common.go_home')}</a>
<p class="opacity-70">${t('auth.admin_required')}</p>
<a href="/auth/login" class="btn btn-primary mt-6">${t('auth.login')}</a>
</div>`, container);
return;
}
@@ -297,7 +297,7 @@ export async function render(container, params, router) {
<div class="text-sm breadcrumbs">
<ul>
<li><a href="/">${t('entities.home')}</a></li>
<li><a href="/a/">${t('entities.admin')}</a></li>
<li><a href="/admin/">${t('entities.admin')}</a></li>
<li>${t('entities.tags')}</li>
</ul>
</div>
@@ -328,21 +328,25 @@ ${flashHtml}
${contentHtml}`, container);
const ac = new AbortController();
const signal = ac.signal;
const on = (el, evt, fn) => el.addEventListener(evt, fn, { signal });
// Event: node selector change
const nodeSelector = container.querySelector('#node-selector');
nodeSelector.addEventListener('change', () => {
on(nodeSelector, 'change', () => {
const pk = nodeSelector.value;
if (pk) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk));
} else {
router.navigate('/a/node-tags');
router.navigate('/admin/node-tags');
}
});
container.querySelector('#load-tags-btn').addEventListener('click', () => {
on(container.querySelector('#load-tags-btn'), 'click', () => {
const pk = nodeSelector.value;
if (pk) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(pk));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(pk));
}
});
@@ -361,15 +365,15 @@ ${contentHtml}`, container);
await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags', {
key, value, value_type,
});
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_added_success', { entity: t('entities.tag') })), true);
} catch (err) {
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
// Edit button handlers
container.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', () => {
on(btn, 'click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
container.querySelector('#editKeyDisplay').value = activeTagKey;
@@ -379,7 +383,7 @@ ${contentHtml}`, container);
});
});
container.querySelector('#editCancel').addEventListener('click', () => {
on(container.querySelector('#editCancel'), 'click', () => {
container.querySelector('#editModal').close();
});
@@ -393,16 +397,16 @@ ${contentHtml}`, container);
value, value_type,
});
container.querySelector('#editModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_updated_success', { entity: t('entities.tag') })), true);
} catch (err) {
container.querySelector('#editModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
// Move button handlers
container.querySelectorAll('.btn-move').forEach(btn => {
btn.addEventListener('click', () => {
on(btn, 'click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
container.querySelector('#moveKeyDisplay').value = activeTagKey;
@@ -411,7 +415,7 @@ ${contentHtml}`, container);
});
});
container.querySelector('#moveCancel').addEventListener('click', () => {
on(container.querySelector('#moveCancel'), 'click', () => {
container.querySelector('#moveModal').close();
});
@@ -425,16 +429,16 @@ ${contentHtml}`, container);
new_public_key: newPublicKey,
});
container.querySelector('#moveModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_moved_success', { entity: t('entities.tag') })), true);
} catch (err) {
container.querySelector('#moveModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
// Delete button handlers
container.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => {
on(btn, 'click', () => {
const row = btn.closest('tr');
activeTagKey = row.dataset.tagKey;
const confirmMsg = t('common.delete_entity_confirm', {
@@ -446,30 +450,30 @@ ${contentHtml}`, container);
});
});
container.querySelector('#deleteCancel').addEventListener('click', () => {
on(container.querySelector('#deleteCancel'), 'click', () => {
container.querySelector('#deleteModal').close();
});
container.querySelector('#deleteConfirm').addEventListener('click', async () => {
on(container.querySelector('#deleteConfirm'), 'click', async () => {
try {
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/' + encodeURIComponent(activeTagKey));
container.querySelector('#deleteModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.entity_deleted_success', { entity: t('entities.tag') })), true);
} catch (err) {
container.querySelector('#deleteModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
// Copy All button
const copyAllBtn = container.querySelector('#btn-copy-all');
if (copyAllBtn) {
copyAllBtn.addEventListener('click', () => {
on(copyAllBtn, 'click', () => {
container.querySelector('#copyAllDestination').selectedIndex = 0;
container.querySelector('#copyAllModal').showModal();
});
container.querySelector('#copyAllCancel').addEventListener('click', () => {
on(container.querySelector('#copyAllCancel'), 'click', () => {
container.querySelector('#copyAllModal').close();
});
@@ -482,38 +486,40 @@ ${contentHtml}`, container);
const result = await apiPost('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags/copy-to/' + encodeURIComponent(destKey));
container.querySelector('#copyAllModal').close();
const msg = t('admin_node_tags.copied_entities', { copied: result.copied, skipped: result.skipped });
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(msg), true);
} catch (err) {
container.querySelector('#copyAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
}, { signal });
}
// Delete All button
const deleteAllBtn = container.querySelector('#btn-delete-all');
if (deleteAllBtn) {
deleteAllBtn.addEventListener('click', () => {
on(deleteAllBtn, 'click', () => {
container.querySelector('#deleteAllModal').showModal();
});
container.querySelector('#deleteAllCancel').addEventListener('click', () => {
on(container.querySelector('#deleteAllCancel'), 'click', () => {
container.querySelector('#deleteAllModal').close();
});
container.querySelector('#deleteAllConfirm').addEventListener('click', async () => {
on(container.querySelector('#deleteAllConfirm'), 'click', async () => {
try {
await apiDelete('/api/v1/nodes/' + encodeURIComponent(selectedPublicKey) + '/tags');
container.querySelector('#deleteAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&message=' + encodeURIComponent(t('common.all_entity_deleted_success', { entity: t('entities.tags').toLowerCase() })), true);
} catch (err) {
container.querySelector('#deleteAllModal').close();
router.navigate('/a/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message));
router.navigate('/admin/node-tags?public_key=' + encodeURIComponent(selectedPublicKey) + '&error=' + encodeURIComponent(err.message), true);
}
});
}
}
return () => ac.abort();
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
@@ -126,9 +126,9 @@ export async function render(container, params, router) {
</div>`
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
const adminTagsHtml = config.admin_enabled
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
? html`<div class="mt-3">
<a href="/a/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
<a href="/admin/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
</div>`
: nothing;
+1 -1
View File
@@ -146,7 +146,7 @@ export class Router {
// Skip non-SPA paths (static files, API, media, OAuth, SEO)
if (href.startsWith('/static/') || href.startsWith('/media/') ||
href.startsWith('/api/') ||
href.startsWith('/api/') || href.startsWith('/auth/') ||
href.startsWith('/health') || href === '/robots.txt' ||
href === '/sitemap.xml') return;
+18 -1
View File
@@ -185,11 +185,22 @@
"admin": {
"access_denied": "Access Denied",
"admin_not_enabled": "The admin interface is not enabled.",
"admin_enable_hint": "Set <code>WEB_ADMIN_ENABLED=true</code> to enable admin features.",
"admin_enable_hint": "Set <code>OIDC_ENABLED=true</code> and configure your OIDC provider to enable admin features.",
"welcome": "Welcome to the admin panel.",
"members_description": "Manage network members and operators.",
"tags_description": "Manage custom tags and metadata for network nodes."
},
"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"
},
"admin_members": {
"network_members": "Network Members ({{count}})",
"member_id": "Member ID",
@@ -215,5 +226,11 @@
},
"footer": {
"powered_by": "Powered by"
},
"errors": {
"go_home": "Go Home",
"not_found": "Page not found",
"server_error": "Internal server error",
"unexpected": "An unexpected error occurred"
}
}
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en" data-theme="{{ theme }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ status_code }} - {{ network_name }}</title>
<style>
:root {
--bg: oklch(0.22 0.014 259);
--card-bg: oklch(0.26 0.014 259);
--text: oklch(0.92 0.004 259);
--text-muted: oklch(0.65 0.004 259);
--accent: oklch(0.65 0.24 265);
--accent-text: oklch(0.98 0.004 265);
}
[data-theme="light"] {
--bg: oklch(0.96 0.006 259);
--card-bg: oklch(1.0 0 0);
--text: oklch(0.22 0.014 259);
--text-muted: oklch(0.50 0.014 259);
--accent: oklch(0.55 0.24 265);
--accent-text: oklch(0.98 0.004 265);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: var(--card-bg);
border-radius: 1rem;
padding: 3rem;
max-width: 28rem;
width: 90%;
text-align: center;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.code {
font-size: 5rem;
font-weight: 800;
line-height: 1;
color: var(--text-muted);
}
.message {
margin-top: 1rem;
font-size: 1.1rem;
color: var(--text);
}
.detail {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.btn {
display: inline-block;
margin-top: 2rem;
padding: 0.6rem 1.8rem;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: 0.5rem;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.version {
margin-top: 2rem;
font-size: 0.7rem;
color: var(--text-muted);
opacity: 0.5;
}
</style>
</head>
<body>
<div class="card">
<div class="code">{{ status_code }}</div>
<div class="message">{{ message }}</div>
{% if detail %}
<div class="detail">{{ detail }}</div>
{% endif %}
<a href="/" class="btn">Go Home</a>
<div class="version">{{ network_name }} &middot; v{{ version }}</div>
</div>
</body>
</html>
+4 -1
View File
@@ -133,6 +133,9 @@
<!-- moon icon - shown in light mode (click to switch to dark) -->
<svg class="swap-on fill-current w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/></svg>
</label>
{% if oidc_enabled %}
<div id="auth-section"></div>
{% endif %}
</div>
</div>
@@ -166,7 +169,7 @@
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">{% if admin_enabled %}<a href="/a/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/admin/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>
+81 -2
View File
@@ -1,7 +1,7 @@
"""Web dashboard test fixtures."""
from typing import Any
from unittest.mock import MagicMock
from typing import Any, Generator
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -321,6 +321,7 @@ def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -
# Ensure tests use a consistent locale regardless of local .env
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
monkeypatch.setenv("OIDC_ENABLED", "false")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -351,6 +352,84 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
return TestClient(web_app, raise_server_exceptions=True)
@pytest.fixture
def web_app_with_oidc(
mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch
) -> Any:
"""Create a web app with OIDC enabled and session middleware."""
monkeypatch.setenv("OIDC_ENABLED", "true")
monkeypatch.setenv("OIDC_CLIENT_ID", "test-client-id")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv(
"OIDC_DISCOVERY_URL", "https://idp.example.com/.well-known/openid-configuration"
)
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
return app
ADMIN_USER = {
"sub": "admin-1",
"name": "Admin User",
"email": "admin@example.com",
"picture": None,
"roles": ["admin", "member"],
}
MEMBER_USER = {
"sub": "member-1",
"name": "Member User",
"email": "member@example.com",
"picture": None,
"roles": ["member"],
}
@pytest.fixture
def client_with_oidc(
web_app_with_oidc: Any, mock_http_client: MockHttpClient
) -> TestClient:
"""Create a test client with OIDC enabled (no session)."""
web_app_with_oidc.state.http_client = mock_http_client
return TestClient(web_app_with_oidc, raise_server_exceptions=True)
@pytest.fixture
def client_with_oidc_admin_session(
web_app_with_oidc: Any, mock_http_client: MockHttpClient
) -> Generator[TestClient, None, None]:
"""Create a test client with OIDC enabled and admin session via mock."""
web_app_with_oidc.state.http_client = mock_http_client
with (
patch("meshcore_hub.web.app.get_session_user", return_value=ADMIN_USER),
patch("meshcore_hub.web.oidc.get_session_user", return_value=ADMIN_USER),
):
yield TestClient(web_app_with_oidc, raise_server_exceptions=True)
@pytest.fixture
def client_with_oidc_member_session(
web_app_with_oidc: Any, mock_http_client: MockHttpClient
) -> Generator[TestClient, None, None]:
"""Create a test client with OIDC enabled and member session via mock."""
web_app_with_oidc.state.http_client = mock_http_client
with (
patch("meshcore_hub.web.app.get_session_user", return_value=MEMBER_USER),
patch("meshcore_hub.web.oidc.get_session_user", return_value=MEMBER_USER),
):
yield TestClient(web_app_with_oidc, raise_server_exceptions=True)
@pytest.fixture
def web_app_no_features(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with all features disabled."""
+58 -37
View File
@@ -1,19 +1,31 @@
"""Tests for admin web routes (SPA)."""
import json
from typing import Any
from typing import Any, Generator
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
from .conftest import MockHttpClient
from .conftest import ADMIN_USER, MockHttpClient
@pytest.fixture
def admin_app(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with admin enabled."""
def admin_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -> Any:
"""Create a web app with OIDC enabled for admin access."""
from .conftest import ALL_FEATURES_ENABLED
monkeypatch.setenv("OIDC_ENABLED", "true")
monkeypatch.setenv("OIDC_CLIENT_ID", "test-client-id")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv(
"OIDC_DISCOVERY_URL",
"https://idp.example.com/.well-known/openid-configuration",
)
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -22,17 +34,19 @@ def admin_app(mock_http_client: MockHttpClient) -> Any:
network_country="Test Country",
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
admin_enabled=True,
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
return app
@pytest.fixture
def admin_app_disabled(mock_http_client: MockHttpClient) -> Any:
"""Create a web app with admin disabled."""
def admin_app_disabled(
mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch
) -> Any:
"""Create a web app with OIDC disabled (admin disabled)."""
monkeypatch.setenv("OIDC_ENABLED", "false")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
@@ -41,7 +55,6 @@ def admin_app_disabled(mock_http_client: MockHttpClient) -> Any:
network_country="Test Country",
network_radio_config="Test Radio Config",
network_contact_email="test@example.com",
admin_enabled=False,
)
app.state.http_client = mock_http_client
@@ -50,10 +63,16 @@ def admin_app_disabled(mock_http_client: MockHttpClient) -> Any:
@pytest.fixture
def admin_client(admin_app: Any, mock_http_client: MockHttpClient) -> TestClient:
"""Create a test client with admin enabled."""
def admin_client(
admin_app: Any, mock_http_client: MockHttpClient
) -> Generator[TestClient, None, None]:
"""Create a test client with OIDC admin session."""
admin_app.state.http_client = mock_http_client
return TestClient(admin_app, raise_server_exceptions=True)
with (
patch("meshcore_hub.web.app.get_session_user", return_value=ADMIN_USER),
patch("meshcore_hub.web.oidc.get_session_user", return_value=ADMIN_USER),
):
yield TestClient(admin_app, raise_server_exceptions=True)
@pytest.fixture
@@ -70,37 +89,32 @@ class TestAdminHome:
In the SPA architecture, admin routes serve the same shell HTML.
Admin access control is handled client-side based on
window.__APP_CONFIG__.admin_enabled.
window.__APP_CONFIG__.is_admin when OIDC is enabled.
"""
def test_admin_home_returns_spa_shell(self, admin_client):
"""Test admin home page returns the SPA shell."""
response = admin_client.get("/a/")
response = admin_client.get("/admin/")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_admin_home_config_admin_enabled(self, admin_client):
"""Test admin config shows admin_enabled: true."""
response = admin_client.get("/a/")
text = response.text
config_start = text.find("window.__APP_CONFIG__ = ") + len(
"window.__APP_CONFIG__ = "
)
config_end = text.find(";", config_start)
config = json.loads(text[config_start:config_end])
assert config["admin_enabled"] is True
def test_admin_home_config_is_admin(self, admin_client):
"""Test admin config shows is_admin: true."""
response = admin_client.get("/admin/")
config = _extract_config(response.text)
assert config["is_admin"] is True
assert config["oidc_enabled"] is True
def test_admin_home_disabled_returns_spa_shell(
self,
admin_client_disabled,
):
"""Test admin page returns SPA shell even when disabled.
"""Test admin page returns SPA shell even when OIDC disabled.
The SPA catch-all serves the shell for all routes.
Client-side code checks admin_enabled to show/hide admin UI.
Client-side code checks oidc_enabled/is_admin to show/hide admin UI.
"""
response = admin_client_disabled.get("/a/")
response = admin_client_disabled.get("/admin/")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
@@ -110,14 +124,14 @@ class TestAdminNodeTags:
def test_node_tags_page_returns_spa_shell(self, admin_client):
"""Test node tags page returns the SPA shell."""
response = admin_client.get("/a/node-tags")
response = admin_client.get("/admin/node-tags")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_node_tags_page_with_public_key(self, admin_client):
"""Test node tags page with public_key param returns SPA shell."""
response = admin_client.get(
"/a/node-tags?public_key=abc123def456abc123def456abc123de",
"/admin/node-tags?public_key=abc123def456abc123def456abc123de",
)
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
@@ -127,7 +141,7 @@ class TestAdminNodeTags:
admin_client_disabled,
):
"""Test node tags page returns SPA shell even when admin is disabled."""
response = admin_client_disabled.get("/a/node-tags")
response = admin_client_disabled.get("/admin/node-tags")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
@@ -135,15 +149,22 @@ class TestAdminNodeTags:
class TestAdminFooterLink:
"""Tests for admin link in footer."""
def test_admin_link_visible_when_enabled(self, admin_client):
"""Test that admin link appears in footer when enabled."""
def test_admin_link_visible_when_oidc_enabled(self, admin_client):
"""Test that admin link appears in footer when OIDC is enabled."""
response = admin_client.get("/")
assert response.status_code == 200
assert 'href="/a/"' in response.text
assert 'href="/admin/"' in response.text
assert "Admin" in response.text
def test_admin_link_hidden_when_disabled(self, admin_client_disabled):
"""Test that admin link does not appear in footer when disabled."""
def test_admin_link_hidden_when_oidc_disabled(self, admin_client_disabled):
"""Test that admin link does not appear in footer when OIDC disabled."""
response = admin_client_disabled.get("/")
assert response.status_code == 200
assert 'href="/a/"' not in response.text
assert 'href="/admin/"' not in response.text
def _extract_config(text: str) -> dict[str, Any]:
"""Extract __APP_CONFIG__ from SPA HTML."""
start = text.find("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = text.find(";", start)
return json.loads(text[start:end]) # type: ignore[no-any-return]
+133
View File
@@ -0,0 +1,133 @@
"""Tests for custom error page rendering."""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from meshcore_hub.web.app import create_app
from .conftest import ALL_FEATURES_ENABLED, MockHttpClient
@pytest.fixture
def error_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -> Any:
"""Create a web app for testing error pages."""
monkeypatch.setenv("OIDC_ENABLED", "false")
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
return app
@pytest.fixture
def error_client(error_app: Any, mock_http_client: MockHttpClient) -> TestClient:
return TestClient(error_app, raise_server_exceptions=False)
@pytest.fixture
def oidc_broken_app(
mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch
) -> Any:
"""Create a web app with OIDC enabled but broken config."""
monkeypatch.setenv("OIDC_ENABLED", "true")
monkeypatch.setenv("OIDC_CLIENT_ID", "broken")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "broken")
monkeypatch.setenv(
"OIDC_DISCOVERY_URL",
"https://nonexistent.example.com/.well-known/openid-configuration",
)
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
return app
@pytest.fixture
def oidc_broken_client(
oidc_broken_app: Any, mock_http_client: MockHttpClient
) -> TestClient:
return TestClient(oidc_broken_app, raise_server_exceptions=False)
class TestUnhandledExceptions:
"""Test custom error page rendering for unhandled exceptions."""
def test_500_returns_html(self, oidc_broken_client: TestClient) -> None:
"""Test 500 from OIDC misconfiguration returns styled HTML."""
response = oidc_broken_client.get("/auth/login", follow_redirects=False)
assert response.status_code == 500
assert "text/html" in response.headers["content-type"]
assert "500" in response.text
assert "Internal server error" in response.text
assert "Go Home" in response.text
def test_500_html_has_no_js_dependency(
self, oidc_broken_client: TestClient
) -> None:
"""Test error page is self-contained (no JS dependencies)."""
response = oidc_broken_client.get("/auth/login", follow_redirects=False)
assert response.status_code == 500
assert "<style>" in response.text
assert "window.__APP_CONFIG__" not in response.text
def test_500_html_shows_network_name(self, oidc_broken_client: TestClient) -> None:
"""Test error page includes the network name."""
response = oidc_broken_client.get("/auth/login", follow_redirects=False)
assert response.status_code == 500
assert "Test Network" in response.text
def test_404_api_returns_json(self, error_client: TestClient) -> None:
"""Test 404 on /api/ path returns JSON."""
response = error_client.get("/api/v1/nonexistent")
assert response.status_code == 404
assert "application/json" in response.headers["content-type"]
data = response.json()
assert "detail" in data
class TestErrorPageTheme:
"""Test error page respects theme setting."""
def test_dark_theme(self, oidc_broken_client: TestClient) -> None:
"""Test error page uses dark theme by default."""
response = oidc_broken_client.get("/auth/login", follow_redirects=False)
assert response.status_code == 500
assert 'data-theme="dark"' in response.text
def test_light_theme(
self, mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test error page uses light theme when configured."""
monkeypatch.setenv("OIDC_ENABLED", "true")
monkeypatch.setenv("OIDC_CLIENT_ID", "broken")
monkeypatch.setenv("OIDC_CLIENT_SECRET", "broken")
monkeypatch.setenv(
"OIDC_DISCOVERY_URL",
"https://nonexistent.example.com/.well-known/openid-configuration",
)
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("WEB_THEME", "light")
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
app = create_app(
api_url="http://localhost:8000",
api_key="test-api-key",
network_name="Test Network",
features=ALL_FEATURES_ENABLED,
)
app.state.http_client = mock_http_client
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/auth/login", follow_redirects=False)
assert response.status_code == 500
assert 'data-theme="light"' in response.text
+411
View File
@@ -0,0 +1,411 @@
"""Tests for OIDC authentication web routes."""
import json
from typing import Any
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from meshcore_hub.web.oidc import init_oidc, strip_userinfo
class TestOIDCSettingsValidation:
"""Test OIDC configuration validation."""
def test_oidc_disabled_by_default(self, client: TestClient) -> None:
"""Test that OIDC is disabled by default."""
response = client.get("/")
assert response.status_code == 200
text = response.text
config = _extract_config(text)
assert config["oidc_enabled"] is False
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
def test_oidc_enabled_config_injection(self, client_with_oidc: TestClient) -> None:
"""Test OIDC config injection when enabled (no session)."""
response = client_with_oidc.get("/")
assert response.status_code == 200
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
class TestAuthLogin:
"""Test /auth/login endpoint."""
def test_login_oidc_disabled(self, client: TestClient) -> None:
"""Test login returns 400 when OIDC disabled."""
response = client.get("/auth/login", follow_redirects=False)
assert response.status_code == 400
def test_login_oidc_enabled(self, client_with_oidc: TestClient) -> None:
"""Test login redirects to IdP when OIDC enabled."""
with patch(
"meshcore_hub.web.app.oauth.oidc.authorize_redirect",
new_callable=AsyncMock,
) as mock_redirect:
from starlette.responses import RedirectResponse
mock_redirect.return_value = RedirectResponse(
url="https://idp.example.com/authorize?state=abc"
)
response = client_with_oidc.get(
"/auth/login?next=/admin/node-tags", follow_redirects=False
)
assert response.status_code == 307
assert "idp.example.com" in response.headers["location"]
class TestAuthCallback:
"""Test /auth/callback endpoint."""
def test_callback_oidc_disabled(self, client: TestClient) -> None:
"""Test callback returns 400 when OIDC disabled."""
response = client.get("/auth/callback", follow_redirects=False)
assert response.status_code == 400
class TestAuthLogout:
"""Test /auth/logout endpoint."""
def test_logout_oidc_disabled(self, client: TestClient) -> None:
"""Test logout returns 400 when OIDC disabled."""
response = client.get("/auth/logout", follow_redirects=False)
assert response.status_code == 400
def test_logout_clears_session(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test logout clears session and redirects via IdP."""
with patch(
"meshcore_hub.web.app.oauth.oidc.logout_redirect",
new_callable=AsyncMock,
) as mock_logout:
from starlette.responses import RedirectResponse
mock_logout.return_value = RedirectResponse(
url="https://idp.example.com/logout"
)
response = client_with_oidc_admin_session.get(
"/auth/logout", follow_redirects=False
)
assert response.status_code == 307
mock_logout.assert_called_once()
call_kwargs = mock_logout.call_args[1]
assert call_kwargs["client_id"] == "test-client-id"
assert "post_logout_redirect_uri" in call_kwargs
def test_logout_falls_back_to_base_url(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test logout uses request.base_url when no redirect URI configured."""
with patch(
"meshcore_hub.web.app.oauth.oidc.logout_redirect",
new_callable=AsyncMock,
) as mock_logout:
from starlette.responses import RedirectResponse
mock_logout.return_value = RedirectResponse(
url="https://idp.example.com/logout"
)
response = client_with_oidc_admin_session.get(
"/auth/logout", follow_redirects=False
)
assert response.status_code == 307
call_kwargs = mock_logout.call_args[1]
assert "post_logout_redirect_uri" in call_kwargs
class TestAuthUser:
"""Test /auth/user endpoint."""
def test_user_oidc_disabled(self, client: TestClient) -> None:
"""Test user endpoint returns 400 when OIDC disabled."""
response = client.get("/auth/user")
assert response.status_code == 400
def test_user_not_authenticated(self, client_with_oidc: TestClient) -> None:
"""Test user endpoint returns 401 when not logged in."""
response = client_with_oidc.get("/auth/user")
assert response.status_code == 401
def test_user_admin_session(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test user endpoint returns admin user."""
response = client_with_oidc_admin_session.get("/auth/user")
assert response.status_code == 200
data = response.json()
assert data["user"]["name"] == "Admin User"
assert data["is_admin"] is True
assert data["is_member"] is True
def test_user_member_session(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test user endpoint returns member user."""
response = client_with_oidc_member_session.get("/auth/user")
assert response.status_code == 200
data = response.json()
assert data["user"]["name"] == "Member User"
assert data["is_admin"] is False
assert data["is_member"] is True
class TestAdminRouteProtection:
"""Test admin route protection when OIDC is enabled."""
def test_no_session_redirects_to_login(self, client_with_oidc: TestClient) -> None:
"""Test admin route redirects to /auth/login without session."""
response = client_with_oidc.get("/admin/", follow_redirects=False)
assert response.status_code == 307
assert "/auth/login" in response.headers["location"]
def test_member_session_gets_spa_shell(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test member session gets SPA shell (client-side shows access denied)."""
response = client_with_oidc_member_session.get("/admin/")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["is_admin"] is False
def test_admin_session_gets_spa_shell(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test admin session gets SPA shell with admin config."""
response = client_with_oidc_admin_session.get("/admin/")
assert response.status_code == 200
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["is_admin"] is True
class TestAPIProxyWriteGating:
"""Test API proxy write method gating when OIDC enabled."""
def test_get_not_gated(self, client_with_oidc: TestClient) -> None:
"""Test GET requests are not gated."""
response = client_with_oidc.get("/api/v1/nodes")
assert response.status_code != 403
def test_post_blocked_for_non_admin(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test POST blocked for member session."""
response = client_with_oidc_member_session.post(
"/api/v1/node-tags", json={"key": "test", "value": "test"}
)
assert response.status_code == 403
def test_post_allowed_for_admin(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test POST allowed for admin session."""
response = client_with_oidc_admin_session.post(
"/api/v1/node-tags",
json={"key": "test", "value": "test"},
)
# Will get 404 from mock since we didn't set up the endpoint,
# but should not get 403
assert response.status_code != 403
def test_write_not_gated_when_oidc_disabled(self, client: TestClient) -> None:
"""Test write methods not gated when OIDC is disabled."""
response = client.post(
"/api/v1/node-tags", json={"key": "test", "value": "test"}
)
assert response.status_code != 403
class TestBackwardCompatibility:
"""Test backward compatibility when OIDC is disabled."""
def test_oidc_disabled_config(self, client: TestClient) -> None:
"""Test config has no admin_enabled when OIDC disabled."""
response = client.get("/")
config = _extract_config(response.text)
assert "admin_enabled" not in config
assert config["oidc_enabled"] is False
assert config["is_admin"] is False
def test_admin_routes_serve_spa_shell_when_oidc_disabled(
self, client: TestClient
) -> None:
"""Test admin routes serve SPA shell when OIDC disabled (no redirect)."""
response = client.get("/admin/")
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_footer_no_admin_link_when_oidc_disabled(self, client: TestClient) -> None:
"""Test footer has no admin link when OIDC disabled."""
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin/"' not in response.text
class TestConfigInjection:
"""Test config injection values for OIDC state."""
def test_admin_session_config(
self, client_with_oidc_admin_session: TestClient
) -> None:
"""Test admin session injects correct config values."""
response = client_with_oidc_admin_session.get("/")
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"]["name"] == "Admin User"
assert config["is_admin"] is True
assert config["is_member"] is True
def test_member_session_config(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test member session injects correct config values."""
response = client_with_oidc_member_session.get("/")
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"]["name"] == "Member User"
assert config["is_admin"] is False
assert config["is_member"] is True
def test_no_session_config(self, client_with_oidc: TestClient) -> None:
"""Test no session injects correct config values."""
response = client_with_oidc.get("/")
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
class TestStripUserinfo:
"""Test strip_userinfo helper function."""
def test_name_from_name_claim(self) -> None:
"""Test name extracted from 'name' claim."""
userinfo = {"sub": "user-1", "name": "John Doe", "email": "john@example.com"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "John Doe"
def test_name_from_preferred_username(self) -> None:
"""Test name falls back to 'preferred_username'."""
userinfo = {"sub": "user-1", "preferred_username": "johndoe"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "johndoe"
def test_name_from_username(self) -> None:
"""Test name falls back to 'username' (LogTo-style)."""
userinfo = {"sub": "user-1", "username": "johndoe"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "johndoe"
def test_name_from_nickname(self) -> None:
"""Test name falls back to 'nickname'."""
userinfo = {"sub": "user-1", "nickname": "johnny"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "johnny"
def test_name_priority_order(self) -> None:
"""Test name claim priority: name > preferred_username > username > nickname."""
userinfo = {
"sub": "user-1",
"name": "Full Name",
"preferred_username": "pref",
"username": "user",
"nickname": "nick",
}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "Full Name"
def test_name_prefers_username_over_nickname(self) -> None:
"""Test username is preferred over nickname when name is absent."""
userinfo = {"sub": "user-1", "username": "logto_user", "nickname": "nick"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] == "logto_user"
def test_name_none_when_all_missing(self) -> None:
"""Test name is None when no name-like claims present."""
userinfo = {"sub": "user-1", "email": "user@example.com"}
result = strip_userinfo(userinfo, "roles")
assert result["name"] is None
def test_roles_extracted(self) -> None:
"""Test roles are extracted from configured claim."""
userinfo = {"sub": "user-1", "custom_roles": ["admin", "member"]}
result = strip_userinfo(userinfo, "custom_roles")
assert result["custom_roles"] == ["admin", "member"]
def test_preserves_sub_email_picture(self) -> None:
"""Test sub, email, and picture are preserved."""
userinfo = {
"sub": "user-1",
"email": "user@example.com",
"picture": "https://example.com/avatar.png",
}
result = strip_userinfo(userinfo, "roles")
assert result["sub"] == "user-1"
assert result["email"] == "user@example.com"
assert result["picture"] == "https://example.com/avatar.png"
class TestInitOidcScopeParsing:
"""Test that init_oidc handles quoted and unquoted scope strings."""
def test_plain_scope_string(self) -> None:
"""Test unquoted scope string is split into list."""
with patch("meshcore_hub.web.oidc.oauth") as mock_oauth:
init_oidc(
"id", "secret", "https://idp.example.com/oidc", "openid email profile"
)
call_kwargs = mock_oauth.register.call_args[1]
assert call_kwargs["client_kwargs"]["scope"] == [
"openid",
"email",
"profile",
]
def test_double_quoted_scope_string(self) -> None:
"""Test double-quoted scope string (from Docker env) is stripped and split."""
with patch("meshcore_hub.web.oidc.oauth") as mock_oauth:
init_oidc(
"id",
"secret",
"https://idp.example.com/oidc",
'"openid email profile"',
)
call_kwargs = mock_oauth.register.call_args[1]
assert call_kwargs["client_kwargs"]["scope"] == [
"openid",
"email",
"profile",
]
def test_single_quoted_scope_string(self) -> None:
"""Test single-quoted scope string is stripped and split."""
with patch("meshcore_hub.web.oidc.oauth") as mock_oauth:
init_oidc(
"id",
"secret",
"https://idp.example.com/oidc",
"'openid email profile'",
)
call_kwargs = mock_oauth.register.call_args[1]
assert call_kwargs["client_kwargs"]["scope"] == [
"openid",
"email",
"profile",
]
def _extract_config(text: str) -> dict[str, Any]:
"""Extract __APP_CONFIG__ from SPA HTML."""
start = text.find("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
end = text.find(";", start)
return json.loads(text[start:end]) # type: ignore[no-any-return]