- Python 88.6%
- HTML 8.1%
- CSS 3%
- Dockerfile 0.2%
Two changes bundled: 1. Float shared-ci-shared from :v1.0.7 (deleted by the morning's "keep last 3 versions" Forgejo prune) to :v1 — survives future prunes. 2. Swap the check / test / audit steps from upstream docker.io/library/python:3.12-slim to loco/shared-python-base:v1 (built today, carries pyflakes / ruff / pip-audit / pytest + system headers for native-extension pip installs). The repo's own `pip install -r requirements.txt` still happens; only the per-step inline tool installs go away. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .woodpecker | ||
| admin | ||
| certs | ||
| core | ||
| docs | ||
| identity | ||
| kubeconfig | ||
| oidc_provider | ||
| portal | ||
| static | ||
| .ci-probe-2 | ||
| .gitignore | ||
| bao.yml | ||
| CHANGELOG.md | ||
| Containerfile | ||
| Makefile | ||
| manage.py | ||
| README.md | ||
| requirements.txt | ||
| sonar-project.properties | ||
Loop Portal
OAuth2/OIDC identity provider and self-service portal for the loop-coop ecosystem, deployed at id.loop-coop.net.
Where this lives. Loop Portal runs as an Incus LXC on
service-machine(10.92.0.23 on the services bridge, gunicorn:8080). Public ingress via the host HAProxy SNI split — TLS terminates with a Let's Encrypt cert issued by the certbot LXC. Migrated off the k8s helm release in talos-hcloud-cluster on 2026-04-27 (THC PR #51); thestack/modules/services/loop-portal.tfand theloop-portalnamespace were removed in the same change.The image is built locally on service-machine via
scripts/build-images.sh loop-portal(distrobuilder) — there's no chart, no helm release, no k8s pod anymore. Database is local SQLite at/var/lib/loop-portal/portal.db(bind-mounted from/var/lib/smdata/loop-portalso it survives image rebuilds). It's consumed as the OIDC issuer by Grafana, Forgejo (viak8s-build-env), MAS, Nextcloud, Hookshot, the Incus UI, andkubectl oidc-login.
Loop Portal wraps OpenBao as the identity backend — all services authenticate through Loop Portal instead of directly against OpenBao. Users manage their identity, passwords, MFA, and API tokens through a single web interface.
Services (Forgejo, Grafana, Matrix via MAS, kubectl)
↓ OAuth2/OIDC
Loop Portal (id.loop-coop.net)
↓ validates credentials + reads identity
OpenBao (mesh-only, no public URL)
Features
OAuth2/OIDC Provider
- Full authorization code flow with PKCE (S256)
- JWT-signed ID tokens and access tokens (RS256)
- OIDC discovery at
/.well-known/openid-configuration - JWKS endpoint at
/.well-known/jwks.json - UserInfo endpoint at
/oauth2/userinfo - Consent management — users approve each application on first login
- Consent stored in OpenBao entity metadata
- Profile page shows authorized applications with revoke
Identity Management
- Login via OpenBao userpass with native MFA TOTP
- Profile view (username, email, groups)
- Password change (requires current password + TOTP if enrolled)
- Wrapped API tokens (one-time-use, 5m unwrap window, 1h TTL)
- TOTP self-service: enroll, verify, disable
- Kubeconfig generation via OpenBao kubernetes secrets engine
Administration
- User list with TOTP enrollment status
- TOTP reset for any user (admins group only)
Observability
- Structured audit logging for all security events (logfmt format)
- Prometheus metrics at
/metrics/(login, OIDC, consent, password, token counters + request duration histogram) - OpenTelemetry tracing (Django + requests instrumented → Alloy → Tempo)
- TraceID/SpanID in all log lines for Loki → Tempo correlation
Security
- Content-Security-Policy:
default-src 'none', strict form-action/frame-ancestors - HSTS with preload (1 year)
- SameSite=Lax cookies (required for OIDC cross-site authorize redirects)
- CSRF protection on all browser-facing endpoints
- Ingress rate limiting: 10 req/s per IP
- ALLOWED_HOSTS restricted to configured hostname + localhost + pod IP (via downward API)
- Input sanitization against log injection (control character stripping)
- Path traversal protection (SHA-256 hashed auth code filenames)
- Entity ID validation before URL construction
- 233 tests including 53 audit tests and property-based fuzzing (hypothesis)
- SonarCloud: 0 bugs, 0 vulnerabilities, Security A
Stack
Django 6, gunicorn (2 workers), whitenoise, authlib (JWT/OIDC), prometheus_client, OpenTelemetry. No JavaScript frameworks, no external CDN, system fonts only.
Architecture
OIDC Flow
- Service redirects user to
/oauth2/authorize?client_id=...&response_type=code&scope=openid+profile+email+groups - If not logged in → redirect to
/login/ - User authenticates against OpenBao userpass
- If MFA enrolled → TOTP challenge at
/login/totp/ - If first authorization for this client → consent page
- Authorization code issued, redirect back to service
- Service exchanges code at
/oauth2/token(with client_secret) - Loop Portal builds JWT claims from OpenBao entity metadata
- ID token + access token returned
- Service fetches
/oauth2/userinfowith Bearer token
Claims
| Scope | Claims |
|---|---|
| openid | sub (entity UUID) |
| profile | preferred_username, nickname, name |
| groups | groups (list of group names) |
Client Configuration
Clients are configured as a static JSON registry in the OIDC_CLIENTS environment variable, sourced from OpenBao KV via ESO. Same client_id/client_secret as the original OpenBao OIDC clients.
Signing Key
RSA 2048-bit key managed by Terraform (tls_private_key), stored in OpenBao KV at infra/portal/oidc, synced to the pod via ESO. JWKS endpoint exposes the public key.
Auth Code Storage
File-based in /tmp/portal/oidc_codes/. Code values are SHA-256 hashed for filenames. 10-minute TTL, single-use (consumed on token exchange). Shared across gunicorn workers via the emptyDir volume.
Consent Storage
Stored as JSON in the OpenBao identity entity's metadata.oidc_consents field. Persists across pod restarts. Visible and revocable from the profile page.
Connected Services
| Service | OIDC Client | Redirect URI |
|---|---|---|
| Grafana | grafana | https://grafana.loop-coop.net/login/generic_oauth |
| Forgejo | forgejo (loop-portal) | https://git.loop-coop.net/user/oauth2/loop-portal/callback |
| Matrix (MAS) | mas | https://mas.loop-coop.net/upstream/callback/... |
| Kubernetes | kubernetes | http://localhost:8000, http://localhost:18000 |
Audit Events
All security-relevant events are logged in logfmt format with traceID for Loki→Tempo correlation.
| Event | Source | Description |
|---|---|---|
login_success |
core | Successful password authentication |
login_failure |
core | Failed login attempt |
mfa_challenge |
core | MFA TOTP prompted |
mfa_success |
core | MFA code accepted |
mfa_failure |
core | MFA code rejected |
logout |
core | User logged out |
session_expired |
middleware | OpenBao token expired, session flushed |
oidc_authorize |
oidc_provider | Authorization request (result: code/denied/error) |
consent_grant |
oidc_provider | User approved client access |
consent_revoke |
identity | User revoked client access |
oidc_token |
oidc_provider | Token exchange (result: success/invalid_client/invalid_grant) |
oidc_userinfo |
oidc_provider | UserInfo request (result: success/invalid_token) |
password_change |
identity | Password changed (result: success/failure) |
totp_enable |
identity | TOTP enrolled |
totp_disable |
identity | TOTP disabled |
token_create |
identity | Wrapped API token created |
token_revoke |
identity | API token revoked |
admin_user_list |
admin | Admin viewed user list |
admin_totp_reset |
admin | Admin reset user's TOTP |
Query in Loki: {app="loop-portal"} |= "event=" | logfmt | event="login_failure"
Prometheus Metrics
| Metric | Type | Labels |
|---|---|---|
loopportal_login_total |
counter | result |
loopportal_oidc_authorize_total |
counter | client, result |
loopportal_oidc_token_total |
counter | client, result |
loopportal_oidc_userinfo_total |
counter | result |
loopportal_consent_total |
counter | action |
loopportal_password_change_total |
counter | result |
loopportal_token_operation_total |
counter | action |
loopportal_request_duration_seconds |
histogram | endpoint |
ServiceMonitor auto-discovered by Prometheus operator (label release: prometheus-operator).
Modules
| Module | URL Prefix | Description |
|---|---|---|
| core | / |
Login, logout, MFA, healthz, metrics |
| oidc_provider | /.well-known/, /oauth2/ |
OIDC discovery, authorize, token, userinfo, JWKS |
| identity | /identity/ |
Profile, password change, tokens, TOTP, consent management |
| kubeconfig | /kubeconfig/ |
Kubernetes credential generation |
| admin | /admin/ |
User list, TOTP reset (admins group only) |
Configuration
| Env var | Required | Description |
|---|---|---|
SECRET_KEY |
yes (prod) | Django secret key (mirrored from k8s bao secret/infra/portal/django.secret_key) |
BAO_ADDR |
yes | OpenBao API address. On service-machine LXC: http://10.92.0.22:8211 (mesh-proxy bao_internal HTTP frontend; mesh-proxy carries the service-machine-client mTLS cert upstream to k8s bao). |
BAO_MFA_TOKEN |
yes | Orphan service token for MFA + OIDC identity ops |
BAO_MFA_METHOD_ID |
yes | TOTP method UUID |
OIDC_ISSUER |
yes | Issuer URL (https://id.loop-coop.net) |
OIDC_SIGNING_KEY_PEM |
yes | RSA private key for JWT signing (PEM) |
OIDC_CLIENTS |
yes | JSON client registry (system clients; user-created clients live in the local SQLite) |
KUBE_API_URL |
yes | Kubernetes API server endpoint |
KUBE_CA_BASE64 |
yes | Cluster CA certificate, base64-encoded |
DB_PATH |
no | SQLite database path. On service-machine LXC: /var/lib/loop-portal/portal.db (bind-mount, persistent across rebuilds). Default: /tmp/portal/portal.db (ephemeral). |
ALLOWED_HOSTS |
no | Comma-separated hostnames (default: id.loop-coop.net,localhost) |
DEBUG |
no | Enable debug mode (default: true) |
OTEL_EXPORTER_OTLP_ENDPOINT |
no | OTLP gRPC endpoint for traces |
Terraform Integration
Managed in two places after the 2026-04-27 migration:
| Resource | Repo / file | Description |
|---|---|---|
tls_private_key.portal_oidc_signing |
THC stack/bootstrap/main.tf |
RSA 2048 signing key (still used) |
vault_kv_secret_v2.portal_oidc |
THC stack/bootstrap/main.tf |
Signing key + client registry JSON in k8s bao under secret/infra/portal/oidc |
vault_policy.portal_mfa |
THC stack/bootstrap/main.tf |
MFA admin + entity read/write policy (k8s bao) |
vault_token.portal_mfa |
THC stack/bootstrap/main.tf |
Orphan service token (BAO_MFA_TOKEN) |
| Cross-bao mirror | aienv/openbao/tofu/loop-portal-secrets.tf |
Mirrors secret/infra/portal/{django,mfa,oidc} from k8s bao to local qubes bao so service-machine mesh-agent can read them |
| LXC + bind-mounts | service-machine/loop-portal.tf |
Incus instance, /var/lib/smdata/loop-portal data device, mesh-agent template renders for env + secrets + qubes-CA |
| ACME cert | service-machine/acme.tf |
terraform_data.certbot_issue["id.loop-coop.net"] issues + installs the LE cert |
| HAProxy SNI split | service-machine/templates/haproxy.cfg.tftpl |
loop_portal_https frontend + bao_internal :8211 backend for Django→bao calls |
helm_release.loop_portal |
REMOVED in THC PR #51 |
CI/CD
Release pipeline (.woodpecker/release.yml): test → helm → container → release
| Artifact | Location |
|---|---|
| Container | git.loop-coop.net/loco/identity-loop-portal:<tag> |
| Helm chart | oci://git.loop-coop.net/projects/charts/loop-portal:<version> |
| Release | git.loop-coop.net/loco/identity-loop-portal/releases |
Development
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
pip install hypothesis coverage
# Run tests (233 tests)
python manage.py test
# Coverage report
coverage run --source=core,oidc_provider,identity,admin manage.py test
coverage report --show-missing
# SonarCloud scan
sonar-scanner -Dsonar.token=<token>
# Dev server
python manage.py runserver
Test Coverage
233 tests: unit, integration, and property-based fuzz tests.
| Area | Tests | Coverage |
|---|---|---|
| core (views, middleware, bao, audit) | ~130 | 96-100% |
| oidc_provider (views, clients, grants, keys, claims, consent) | ~80 | 89-100% |
| identity (profile, password, tokens, TOTP) | ~20 | 71% |
| admin (user list, TOTP reset) | ~10 | 96% |
| Fuzz (hypothesis) | 12 | Path traversal, log injection, protocol abuse |
Audit tests (53) cover all 19 event types across 13 test classes:
- Sanitization and log injection resistance
- Integration tests for login, logout, MFA, session expiry flows
- OIDC authorize/token/userinfo audit paths
- Password change, token management, TOTP, admin operations
- Event format consistency validation