Loop Portal Django IdP
  • Python 88.6%
  • HTML 8.1%
  • CSS 3%
  • Dockerfile 0.2%
Find a file
statevault 9067aae611 chore(ci): pin shared-ci-shared:v1, swap python:3.12-slim → shared-python-base:v1
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>
2026-05-13 12:20:03 +02:00
.woodpecker chore(ci): pin shared-ci-shared:v1, swap python:3.12-slim → shared-python-base:v1 2026-05-13 12:20:03 +02:00
admin Add property-based fuzz tests with hypothesis (197 total) 2026-04-11 02:05:33 +02:00
certs feat(certs): add cert listing and revocation 2026-04-11 21:31:12 +02:00
core fix(auth): merge token-auth entity into userpass entity for mTLS sessions 2026-04-27 13:10:12 +02:00
docs docs: deployment + operational state on service-machine 2026-05-09 18:33:48 +02:00
identity fix(profile): tolerate bao unreachable when checking TOTP status 2026-04-12 09:21:10 +02:00
kubeconfig Add kubeconfig module, require TOTP on password change 2026-04-10 17:42:51 +02:00
oidc_provider feat(oidc): support public clients (PKCE, token_endpoint_auth_method=none) 2026-04-29 10:35:11 +02:00
portal feat(mail): wire EMAIL_* env vars for outbound SMTP 2026-04-29 13:20:16 +02:00
static feat(home): add mTLS dev services section 2026-04-15 11:02:08 +02:00
.ci-probe-2 chore(ci): probe after trusted.network flip 2026-05-04 13:48:52 +02:00
.gitignore chore(ci): consolidate to single pipeline + unified bao-checks 2026-05-01 17:30:26 +02:00
bao.yml chore(ci): consolidate to single pipeline + unified bao-checks 2026-05-01 17:30:26 +02:00
CHANGELOG.md chore(release): v0.15.2 [skip ci] 2026-05-13 08:18:22 +00:00
Containerfile chore(image): pin python base to slim-trixie (Debian 13) 2026-05-04 12:49:58 +02:00
Makefile Initial scaffold: Django self-service portal 2026-04-02 21:49:32 +02:00
manage.py Initial scaffold: Django self-service portal 2026-04-02 21:49:32 +02:00
README.md chore: scrub registry paths to loco/<group>-<project> 2026-05-01 19:44:10 +02:00
requirements.txt fix(deps): bump cryptography to >=46.0.6 (CVE-2026-26007, CVE-2026-34073) 2026-04-11 18:42:55 +02:00
sonar-project.properties chore: remove unused helm chart + helm CI step 2026-04-27 10:56:16 +02:00

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); the stack/modules/services/loop-portal.tf and the loop-portal namespace 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-portal so it survives image rebuilds). It's consumed as the OIDC issuer by Grafana, Forgejo (via k8s-build-env), MAS, Nextcloud, Hookshot, the Incus UI, and kubectl 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

  1. Service redirects user to /oauth2/authorize?client_id=...&response_type=code&scope=openid+profile+email+groups
  2. If not logged in → redirect to /login/
  3. User authenticates against OpenBao userpass
  4. If MFA enrolled → TOTP challenge at /login/totp/
  5. If first authorization for this client → consent page
  6. Authorization code issued, redirect back to service
  7. Service exchanges code at /oauth2/token (with client_secret)
  8. Loop Portal builds JWT claims from OpenBao entity metadata
  9. ID token + access token returned
  10. Service fetches /oauth2/userinfo with Bearer token

Claims

Scope Claims
openid sub (entity UUID)
profile preferred_username, nickname, name
email email
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.

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 services/loop-portal.tf 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