diff --git a/.env.example b/.env.example index 67f1def..7fc4bdd 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ OIDC_REDIRECT_URI=http://127.0.0.1:8008/auth/callback/ # Public client — no secret: # OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email groups +# Ordinary sign-in is AAL1; sensitive actions request this ACR explicitly. +OIDC_STEP_UP_ACR=aal2 +# Enable the landing-page registration link when public registration is deployed. +# NETKINGDOM_REGISTRATION_URL=https://users.coulomb.social/register?client_id=coulomb-social USER_ENGINE_APPLICATION_ID=coulomb-social USER_ENGINE_EXPECTED_AUDIENCE=user-engine-portal diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 9234f97..1d9e341 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -10,6 +10,7 @@ | --- | --- | --- | --- | --- | | workplan | CSOC-WP-0001 | active | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | | workplan | CSOC-WP-0002 | active | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | +| workplan | CSOC-WP-0003 | active | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md | | task | CSOC-WP-0001-T01 | todo | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | | task | CSOC-WP-0001-T02 | todo | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | | task | CSOC-WP-0001-T03 | wait | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md | @@ -22,3 +23,7 @@ | task | CSOC-WP-0002-T06 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | task | CSOC-WP-0002-T07 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | | task | CSOC-WP-0002-T08 | done | — | workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md | +| task | CSOC-WP-0003-T01 | done | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md | +| task | CSOC-WP-0003-T02 | progress | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md | +| task | CSOC-WP-0003-T03 | done | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md | +| task | CSOC-WP-0003-T04 | todo | — | workplans/CSOC-WP-0003-self-registration-and-assurance.md | diff --git a/coulomb_social/apps/core/context_processors.py b/coulomb_social/apps/core/context_processors.py index 49ba21f..5821bad 100644 --- a/coulomb_social/apps/core/context_processors.py +++ b/coulomb_social/apps/core/context_processors.py @@ -6,4 +6,5 @@ def site_context(request): "site_name": "coulomb.social", "default_tenant_id": settings.DEFAULT_TENANT_ID, "oidc_enabled": settings.OIDC_ENABLED, + "registration_enabled": bool(settings.NETKINGDOM_REGISTRATION_URL), } diff --git a/coulomb_social/apps/identity/oidc.py b/coulomb_social/apps/identity/oidc.py index 90ad98a..9c6d998 100644 --- a/coulomb_social/apps/identity/oidc.py +++ b/coulomb_social/apps/identity/oidc.py @@ -2,14 +2,17 @@ from __future__ import annotations +import logging import secrets from typing import Any -from urllib.parse import urlencode import httpx from authlib.integrations.httpx_client import OAuth2Client +from authlib.jose import JsonWebKey, jwt from django.conf import settings +logger = logging.getLogger(__name__) + class OIDCConfigurationError(RuntimeError): pass @@ -52,17 +55,18 @@ def _oauth_client() -> OAuth2Client: return OAuth2Client(**kwargs) -def build_authorization_url(*, state: str, code_verifier: str) -> str: +def build_authorization_url( + *, state: str, code_verifier: str, acr_values: str | None = None +) -> str: if not oidc_configured(): raise OIDCConfigurationError("OIDC is not enabled/configured") doc = discovery_document() auth_endpoint = doc["authorization_endpoint"] client = _oauth_client() - uri, _ = client.create_authorization_url( - auth_endpoint, - state=state, - code_verifier=code_verifier, - ) + parameters = {"state": state, "code_verifier": code_verifier} + if acr_values: + parameters["acr_values"] = acr_values + uri, _ = client.create_authorization_url(auth_endpoint, **parameters) return uri @@ -80,17 +84,54 @@ def exchange_code(code: str, *, code_verifier: str) -> dict[str, Any]: def fetch_userinfo(access_token: str) -> dict[str, Any]: + """Best-effort userinfo. KeyCape may 401 for some subjects; id_token is enough.""" + if not access_token: + return {} doc = discovery_document() userinfo_endpoint = doc.get("userinfo_endpoint") if not userinfo_endpoint: return {} - resp = httpx.get( - userinfo_endpoint, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=15.0, + try: + resp = httpx.get( + userinfo_endpoint, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15.0, + ) + if resp.status_code >= 400: + logger.warning( + "OIDC userinfo returned %s; continuing with id_token claims", + resp.status_code, + ) + return {} + return resp.json() + except Exception: + logger.exception("OIDC userinfo request failed; continuing with id_token claims") + return {} + + +def decode_id_token(id_token: str) -> dict[str, Any]: + """Verify id_token with issuer JWKS and return claims.""" + if not id_token or id_token.count(".") != 2: + raise OIDCConfigurationError("token response missing a usable id_token") + doc = discovery_document() + jwks_uri = doc.get("jwks_uri") + if not jwks_uri: + raise OIDCConfigurationError("issuer discovery missing jwks_uri") + jwks = httpx.get(jwks_uri, timeout=15.0) + jwks.raise_for_status() + key_set = JsonWebKey.import_key_set(jwks.json()) + claims = jwt.decode( + id_token, + key_set, + claims_options={ + "iss": {"essential": True, "value": settings.OIDC_ISSUER.rstrip("/")}, + "aud": {"essential": True, "value": settings.OIDC_CLIENT_ID}, + "exp": {"essential": True}, + "sub": {"essential": True}, + }, ) - resp.raise_for_status() - return resp.json() + claims.validate() + return dict(claims) def new_pkce_pair() -> tuple[str, str]: @@ -101,10 +142,16 @@ def new_pkce_pair() -> tuple[str, str]: def claims_from_token_response(token: dict[str, Any], userinfo: dict[str, Any]) -> dict[str, Any]: - """Merge id_token claims (if present as dict) with userinfo.""" + """Prefer verified id_token claims; overlay optional userinfo.""" claims: dict[str, Any] = {} - # authlib may leave id_token as JWT string; userinfo is preferred when available - claims.update(userinfo or {}) - if not claims.get("sub") and isinstance(token.get("userinfo"), dict): + id_token = token.get("id_token") + if isinstance(id_token, str) and id_token: + claims.update(decode_id_token(id_token)) + elif isinstance(token.get("userinfo"), dict): claims.update(token["userinfo"]) + # userinfo is optional enrichment (KeyCape may 401 for some subjects) + if userinfo: + claims.update(userinfo) + if not claims.get("sub"): + raise OIDCConfigurationError("OIDC response has no subject claim") return claims diff --git a/coulomb_social/apps/identity/urls.py b/coulomb_social/apps/identity/urls.py index d6ba154..2a3df5d 100644 --- a/coulomb_social/apps/identity/urls.py +++ b/coulomb_social/apps/identity/urls.py @@ -6,6 +6,7 @@ app_name = "identity" urlpatterns = [ path("login/", views.login_start, name="login"), + path("register/", views.registration_start, name="register"), path("callback/", views.oidc_callback, name="callback"), path("logout/", views.logout_view, name="logout"), path("dev-login/", views.dev_login, name="dev_login"), diff --git a/coulomb_social/apps/identity/views.py b/coulomb_social/apps/identity/views.py index 5e4c114..47f7300 100644 --- a/coulomb_social/apps/identity/views.py +++ b/coulomb_social/apps/identity/views.py @@ -20,6 +20,7 @@ logger = logging.getLogger(__name__) SESSION_OIDC_STATE = "oidc_state" SESSION_OIDC_VERIFIER = "oidc_code_verifier" +SESSION_OIDC_REQUIRED_ACR = "oidc_required_acr" @require_GET @@ -31,8 +32,19 @@ def login_start(request: HttpRequest) -> HttpResponse: state, verifier = oidc.new_pkce_pair() request.session[SESSION_OIDC_STATE] = state request.session[SESSION_OIDC_VERIFIER] = verifier + requested_acr = ( + settings.OIDC_STEP_UP_ACR + if request.GET.get("assurance") == "aal2" + else None + ) + if requested_acr: + request.session[SESSION_OIDC_REQUIRED_ACR] = requested_acr + else: + request.session.pop(SESSION_OIDC_REQUIRED_ACR, None) try: - url = oidc.build_authorization_url(state=state, code_verifier=verifier) + url = oidc.build_authorization_url( + state=state, code_verifier=verifier, acr_values=requested_acr + ) except Exception: logger.exception("OIDC authorization URL build failed") messages.error(request, "Identity provider is unavailable. Try again later.") @@ -60,6 +72,7 @@ def oidc_callback(request: HttpRequest) -> HttpResponse: state = request.GET.get("state") expected_state = request.session.pop(SESSION_OIDC_STATE, None) verifier = request.session.pop(SESSION_OIDC_VERIFIER, None) + required_acr = request.session.pop(SESSION_OIDC_REQUIRED_ACR, None) if not code or not state or state != expected_state or not verifier: return HttpResponseBadRequest("Invalid OIDC callback state") @@ -77,11 +90,34 @@ def oidc_callback(request: HttpRequest) -> HttpResponse: return HttpResponseBadRequest("Token missing subject") issuer = raw.get("iss") or settings.OIDC_ISSUER + if required_acr and not _claims_satisfy_step_up(raw, required_acr): + logger.warning("OIDC response did not satisfy requested assurance") + return HttpResponseBadRequest("Requested sign-in assurance was not satisfied") claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub)) establish_session(request, claims) return redirect(settings.LOGIN_REDIRECT_URL) +@require_GET +def registration_start(request: HttpRequest) -> HttpResponse: + """Send applicants only to the operator-configured registration service.""" + url = settings.NETKINGDOM_REGISTRATION_URL + if not url: + messages.error(request, "Account registration is not available yet.") + return redirect("core:landing") + return redirect(url) + + +def _claims_satisfy_step_up(raw: dict, required_acr: str) -> bool: + assurance = raw.get("assurance") if isinstance(raw.get("assurance"), dict) else {} + acr = str(raw.get("acr") or assurance.get("aal") or "").lower() + if required_acr.lower() in {"aal2", "urn:netkingdom:aal2", "mfa"}: + return acr in {"aal2", "urn:netkingdom:aal2", "mfa"} or bool( + assurance.get("mfa") + ) + return acr == required_acr.lower() + + @require_http_methods(["GET", "POST"]) def dev_login(request: HttpRequest) -> HttpResponse: """Local-only claims form when OIDC is off. Never enable outside DEBUG.""" diff --git a/coulomb_social/settings/base.py b/coulomb_social/settings/base.py index b43eb6f..1ba7db1 100644 --- a/coulomb_social/settings/base.py +++ b/coulomb_social/settings/base.py @@ -101,6 +101,8 @@ OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", default="") OIDC_REDIRECT_URI = config("OIDC_REDIRECT_URI", default="") OIDC_SCOPES = config("OIDC_SCOPES", default="openid profile email groups") OIDC_DISCOVERY_URL = config("OIDC_DISCOVERY_URL", default="") # optional override +OIDC_STEP_UP_ACR = config("OIDC_STEP_UP_ACR", default="aal2") +NETKINGDOM_REGISTRATION_URL = config("NETKINGDOM_REGISTRATION_URL", default="") # user-engine HTTP (empty base or secret → offline stub) USER_ENGINE_BASE_URL = config("USER_ENGINE_BASE_URL", default="") diff --git a/coulomb_social/templates/core/landing.html b/coulomb_social/templates/core/landing.html index 113fbbe..71796e4 100644 --- a/coulomb_social/templates/core/landing.html +++ b/coulomb_social/templates/core/landing.html @@ -8,6 +8,9 @@
Sign in + {% if registration_enabled %} + Create account + {% endif %}
Tenant: {{ default_tenant_id }} · OIDC: diff --git a/docs/deploy.md b/docs/deploy.md index 5834103..96c6a83 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,11 +1,30 @@ -# Deploy notes (stub — CSOC-WP-0002-T08) +# Deploy notes ## Shape Standalone service: commit-SHA images → registry -`gitea.coulomb.social/coulomb/coulomb-social` → `railiance-apps` values → +`forgejo.coulomb.social/coulomb/coulomb-social` → `railiance-apps` Helm values → railiance01 (same lane as `vergabe-teilnahme`). +Chart/values/ingress live in **`railiance-apps`** +(`helm/coulomb-social-values.yaml`, `docs/coulomb-social.md`). + +## Current cluster status (2026-08-09) + +| Item | State | +|------|--------| +| Namespace | `coulomb-social` Active | +| Deployment | 1/1 Ready, image `:7067145` | +| Service | ClusterIP :80 | +| Ingress | `coulomb.social` → Traefik, cert-manager annotation | +| Env secret | `coulomb-social-env` (`SECRET_KEY`, `DATABASE_URL`, `USER_ENGINE_PROXY_SECRET`) | +| OIDC | enabled; issuer `https://kc.coulomb.social`; public client | +| Public DNS | **still Cloudflare / Bubble** | +| TLS secret | **pending** HTTP-01 until DNS points at the cluster | + +In-cluster smoke (with `Host: coulomb.social`): `/healthz` ok, landing 200, +`/auth/login/` → KeyCape authorize. Full browser session needs cutover. + ## Runtime secrets (names only) K8s Secret `coulomb-social-env` in namespace `coulomb-social` (chart `envFrom`): @@ -26,24 +45,82 @@ make coulomb-social-env-secret ./scripts/create-env-secret.sh ``` -Script: `railiance-apps/tools/create-coulomb-social-env-secret.sh` OIDC is a **public** client — no client secret. + +Non-secret env (OIDC, ALLOWED_HOSTS, user-engine URL) lives in Helm values. + ## Health -- `GET /healthz` → `{"status":"ok"}` +- `GET /healthz` → `{"status":"ok","service":"coulomb-social"}` +- Probes use `Host: coulomb.social` (`probes.hostHeader`) -## Build +## Build / deploy ```bash SHA=$(git rev-parse --short HEAD) docker build -t forgejo.coulomb.social/coulomb/coulomb-social:$SHA . +# push, then: +# COULOMB_SOCIAL_IMAGE_TAG=$SHA make coulomb-social-deploy # in railiance-apps ``` -Runtime env (no secrets in image): `SECRET_KEY`, `DATABASE_URL`, -`OIDC_*`, `USER_ENGINE_*`, `DEFAULT_TENANT_ID`, `ALLOWED_HOSTS`. +## Cutover checklist (DNS → live Railiance) -## Status +**Goal:** `https://coulomb.social` serves this app (identity shell), not Bubble. -- Dockerfile present (gunicorn, non-root, `/healthz` check). -- railiance-apps Helm values / cluster Service **not** yet landed. -- Local `make run` + `make test` remain the default verification path. +### Preconditions + +1. [x] Image + Helm release healthy +2. [x] KeyCape client `coulomb-social` with prod redirect `https://coulomb.social/auth/callback/` +3. [x] In-cluster OIDC start redirect works +4. [ ] Local browser OIDC + MFA completed once (proves IdP + user-engine path) +5. [ ] Operator accepts brief public outage / Bubble freeze during DNS switch +6. [ ] Optional: export Bubble data if still needed (CSOC-WP-0001) — not required for identity-only cutover + +### DNS switch + +1. In Cloudflare (or DNS host): lower TTL on `coulomb.social` if possible (e.g. 300s) ahead of time. +2. Point apex (and `www` if used) **A** to **`92.205.62.239`** (railiance01 ingress). + - Prefer DNS-only (grey cloud) first so LE HTTP-01 and Traefik see real traffic; re-enable proxy only if you understand TLS termination path. +3. Wait for propagation: `dig +short coulomb.social A` → `92.205.62.239`. +4. cert-manager should finish HTTP-01; confirm: + + ```bash + kubectl -n coulomb-social get certificate coulomb-social-tls + # READY=True + ``` + +5. Smoke public HTTPS: + + ```bash + curl -fsS https://coulomb.social/healthz + curl -sI https://coulomb.social/auth/login/ | grep -i location + # Location: https://kc.coulomb.social/authorize?... + ``` + +6. **Browser:** Sign in → Authelia MFA → land on `/app/` with principal. +7. Sign out; confirm `/app/` requires login. +8. Second login: same member row / user-engine user_id. + +### Rollback + +- Point DNS A (or Cloudflare origin) back to Bubble/Cloudflare target. +- Cluster release can stay; it only receives traffic when DNS aims at the node. + +### After cutover residuals + +| Item | Note | +|------|------| +| Bubble freeze | Stop editing live Bubble as source of truth | +| Content/UI | CSOC-WP-0001 + design extract — not required for identity shell | +| flex-auth Service | leave `FLEX_AUTH_BASE_URL` unset (local vocabulary) until PDP exists | +| apps-pg backup/HA | business-app contract | +| OpenBao CCR | replace kubectl-sourced env secret when ready | +| Image CI | Forgejo/Gitea pipeline for SHA tags | + +## Local verification (no cutover) + +```bash +make test +make run # offline identity +# or OIDC vars from docs/identity/oidc-client.md +``` diff --git a/docs/identity/smoke.md b/docs/identity/smoke.md index b0f6079..0d1cca2 100644 --- a/docs/identity/smoke.md +++ b/docs/identity/smoke.md @@ -1,23 +1,101 @@ # Identity smoke checklist -## Offline (dev claims) +Evidence updated: **2026-08-09**. -1. `uv sync && uv run manage.py migrate && uv run manage.py runserver 8008` -2. Open `/` → **Sign in** -3. Dev form → submit subject `smoke-1` -4. Land on `/app/` with display name and subject shown -5. **Sign out** → back to landing; `/app/` redirects to login -6. Sign in again with same subject → single `Member` row (idempotent) +## Offline (dev claims) — **passed** -## With platform OIDC +```bash +uv sync && uv run manage.py migrate && make run +# OIDC_ENABLED=false (default), DEBUG=true +``` -1. Set `OIDC_ENABLED=true` and issuer/client/redirect env vars -2. Register redirect URI at the issuer (no wildcards) -3. `/auth/login/` redirects to IdP; callback creates/links Member -4. Logout clears app session +| Step | Result | +|------|--------| +| Open `/` → **Sign in** | → `/auth/dev-login/` | +| Dev form subject `smoke-1` | 302 → `/app/` | +| Shell shows display name + subject | OK | +| **Sign out** | session cleared | +| `/app/` after logout | 302 → login | +| Second login same subject | single `Member` row (idempotent) | +| `make test` | **15 passed** | + +Automated POST probe (2026-08-09): + +```text +dev_login_post → /app/ 200 with subject smoke-1 +logout → app 302 to /auth/login/?next=/app/ +``` + +## Cluster in-cluster (port-forward) — **passed (start of OIDC)** + +DNS for `coulomb.social` still points at Cloudflare/Bubble; TLS ACME is +blocked until cutover. Smoke via: + +```bash +kubectl -n coulomb-social port-forward svc/coulomb-social 18088:80 +curl -H 'Host: coulomb.social' http://127.0.0.1:18088/healthz +# {"status": "ok", "service": "coulomb-social"} +``` + +| Check | Result | +|-------|--------| +| Image | `forgejo.coulomb.social/coulomb/coulomb-social:7067145` | +| `OIDC_ENABLED` | `true` (values) | +| `GET /healthz` + Host | 200 JSON ok | +| `GET /` + Host | 200 landing shell | +| `GET /auth/login/` + Host | **302** → `https://kc.coulomb.social/authorize?...` with `client_id=coulomb-social`, `redirect_uri=https://coulomb.social/auth/callback/`, PKCE S256 | +| Session cookie | `HttpOnly; Secure; SameSite=Lax` (prod settings) | + +Full browser login against the **cluster** redirect URI requires public HTTPS +on `coulomb.social` (Secure cookie + callback host). Use **local OIDC** below +before DNS cutover, or complete browser MFA after cutover. + +## Platform OIDC (local redirect) — **ready for human MFA** + +Client registration and authorize handoff verified; **human Authelia + MFA** +is the remaining interactive step. + +```bash +export OIDC_ENABLED=true +export OIDC_ISSUER=https://kc.coulomb.social +export OIDC_CLIENT_ID=coulomb-social +export OIDC_REDIRECT_URI=http://127.0.0.1:8008/auth/callback/ +export OIDC_SCOPES="openid profile email groups" +# optional live user-engine (else stub): +# export USER_ENGINE_BASE_URL=https://users.92-205-62-239.nip.io +# export USER_ENGINE_PROXY_SECRET="$(kubectl -n user-engine get secret user-engine-runtime \ +# -o jsonpath='{.data.proxy-secret}' | base64 -d)" +make run +``` + +| Step | Expected | +|------|----------| +| Open http://127.0.0.1:8008/ → Sign in | redirect KeyCape → Authelia | +| Complete MFA | callback → `/app/` with subject / display name | +| Sign out | landing; `/app/` requires login | +| Second login | same Member / user_engine user_id | + +Authorize probe (no browser) 2026-08-09: + +| redirect_uri | KeyCape | +|--------------|---------| +| `http://127.0.0.1:8008/auth/callback/` | **302** → Authelia OIDC | +| `https://coulomb.social/auth/callback/` | **302** → Authelia OIDC | + +Unregistered redirects still fail with `invalid_profile_usage` (T03). ## Automated ```bash make test ``` + +## Blockers for production hostname smoke + +| Blocker | Detail | +|---------|--------| +| Public DNS | `coulomb.social` → Cloudflare `104.*` (Bubble), not `92.205.62.239` | +| TLS cert | `certificate/coulomb-social-tls` **not Ready**; HTTP-01 challenge gets **404** from public edge (LE never reaches cluster solver) | +| Secure cookies | prod `SESSION_COOKIE_SECURE=True` — need HTTPS after cutover | + +See cutover steps in `docs/deploy.md`. diff --git a/tests/test_oidc_claims.py b/tests/test_oidc_claims.py new file mode 100644 index 0000000..d7ce741 --- /dev/null +++ b/tests/test_oidc_claims.py @@ -0,0 +1,110 @@ +"""Unit tests for OIDC claim assembly (no live issuer).""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from django.test import override_settings + +from coulomb_social.apps.identity import oidc + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_claims_prefer_id_token_when_userinfo_empty(): + token = {"id_token": "header.payload.sig"} + fake_claims = { + "iss": "https://kc.example.test", + "sub": "platform-root", + "aud": "coulomb-social", + "name": "Platform Root", + "preferred_username": "platform-root", + "tenant": "tenant:coulomb", + } + with patch.object(oidc, "decode_id_token", return_value=fake_claims) as dec: + out = oidc.claims_from_token_response(token, {}) + dec.assert_called_once_with("header.payload.sig") + assert out["sub"] == "platform-root" + assert out["name"] == "Platform Root" + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_userinfo_overlays_id_token(): + token = {"id_token": "h.p.s"} + with patch.object( + oidc, + "decode_id_token", + return_value={"sub": "u1", "name": "From Token", "email": ""}, + ): + out = oidc.claims_from_token_response( + token, {"name": "From Userinfo", "email": "a@b.c"} + ) + assert out["sub"] == "u1" + assert out["name"] == "From Userinfo" + assert out["email"] == "a@b.c" + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_missing_sub_raises(): + with patch.object(oidc, "decode_id_token", return_value={}): + with pytest.raises(oidc.OIDCConfigurationError, match="no subject"): + oidc.claims_from_token_response({"id_token": "h.p.s"}, {}) + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="http://127.0.0.1:8008/auth/callback/", +) +def test_fetch_userinfo_soft_fails_on_401(httpx_mock=None): + import httpx + + class FakeResp: + status_code = 401 + + def json(self): + return {"error": "invalid_token"} + + with ( + patch.object( + oidc, + "discovery_document", + return_value={"userinfo_endpoint": "https://kc.example.test/userinfo"}, + ), + patch.object(httpx, "get", return_value=FakeResp()), + ): + assert oidc.fetch_userinfo("opaque-or-jwt") == {} + + +@override_settings( + OIDC_ENABLED=True, + OIDC_ISSUER="https://kc.example.test", + OIDC_CLIENT_ID="coulomb-social", + OIDC_REDIRECT_URI="https://coulomb.example.test/auth/callback/", +) +def test_authorization_url_can_request_aal2(): + with patch.object( + oidc, + "discovery_document", + return_value={"authorization_endpoint": "https://kc.example.test/authorize"}, + ): + url = oidc.build_authorization_url( + state="state", code_verifier="verifier", acr_values="aal2" + ) + assert "acr_values=aal2" in url diff --git a/tests/test_shell.py b/tests/test_shell.py index 85c60ee..6dd05d0 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -20,6 +20,16 @@ def test_landing_public(client): assert b"Sign in" in r.content +@pytest.mark.django_db +def test_registration_link_uses_only_configured_destination(client, settings): + settings.NETKINGDOM_REGISTRATION_URL = ( + "https://users.coulomb.social/register?client_id=coulomb-social" + ) + r = client.get(reverse("identity:register") + "?next=https://evil.example") + assert r.status_code == 302 + assert r["Location"] == settings.NETKINGDOM_REGISTRATION_URL + + @pytest.mark.django_db def test_app_home_requires_login(client): r = client.get(reverse("core:app_home")) diff --git a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md index 8b6304d..288a717 100644 --- a/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md +++ b/workplans/CSOC-WP-0002-netkingdom-user-management-reestablish.md @@ -268,6 +268,8 @@ Align with business-app delivery lane without full production cutover: 2026-08-09: `Dockerfile` added; `railiance-apps` chart + values + ingress stub + Makefile targets. Image `7067145` published and Helm release deployed; migrations applied; in-cluster /healthz+landing OK. Public DNS still Cloudflare/Bubble; TLS cert pending DNS cutover to 92.205.62.239. +2026-08-09 (smoke continuation): Offline checklist + `make test` (15) passed. Port-forward with `Host: coulomb.social`: healthz/landing OK; `/auth/login/` 302 to KeyCape with prod redirect + PKCE. KeyCape authorize accepts local and prod redirect URIs (→ Authelia). Full browser MFA login still human step (`docs/identity/smoke.md`); cutover steps in `docs/deploy.md`. + --- ## Sequencing diff --git a/workplans/CSOC-WP-0003-self-registration-and-assurance.md b/workplans/CSOC-WP-0003-self-registration-and-assurance.md new file mode 100644 index 0000000..9d8f15d --- /dev/null +++ b/workplans/CSOC-WP-0003-self-registration-and-assurance.md @@ -0,0 +1,96 @@ +--- +id: CSOC-WP-0003 +type: workplan +title: "Add NetKingdom self-registration and profile-aware assurance" +domain: communication +repo: coulomb-social +status: active +owner: codex +topic_slug: coulomb-social +created: "2026-08-09" +updated: "2026-08-09" +depends_on: + - CSOC-WP-0002 + - NK-WP-0025 + - USER-WP-0022 + - KEY-WP-0008 +state_hub_workstream_id: "7cd7d6b8-e01d-4b34-8680-3c0cac68d80e" +--- + +# CSOC-WP-0003 - self-registration and assurance + +Extend the working CSOC-WP-0002 OIDC/JIT shell with a NetKingdom account +creation entry point and optional profile/action step-up. + +## T01 - Preserve and prove first-login JIT profile creation + +```task +id: CSOC-WP-0003-T01 +status: done +priority: high +state_hub_task_id: "dde13170-7203-4fcd-b0ce-5874fccc4632" +``` + +Harden the existing issuer/subject keyed Member creation, concurrent callback +behavior, verified ID-token processing, and user-engine link. Preserve the +current uncommitted CSOC-WP-0002 claim-verification work. + +Done when an existing LLDAP identity gets exactly one ordinary Member and +repeat login updates safe display fields without changing identity ownership. + +Covered by the issuer/subject uniqueness constraint, unusable local passwords, +idempotent session establishment tests, and verified ID-token claim handling. + +## T02 - Add Create NetKingdom account + +```task +id: CSOC-WP-0003-T02 +status: progress +priority: high +state_hub_task_id: "aaf2d2cb-6ba9-42cb-9271-aacc414e947a" +``` + +Add a landing-page registration choice using the configured NetKingdom public +registration URL. The configured URL owns any signed return context. Completion must +start a fresh OIDC flow before creating an application session. + +Done when a new user can leave coulomb.social, register, and return through +the same callback/JIT path without open redirects. + +The application entry point is implemented and ignores browser-supplied +redirect parameters. It remains disabled until the NetKingdom public +registration URL and verified-mail flow are deployed. + +## T03 - Support profile/action step-up + +```task +id: CSOC-WP-0003-T03 +status: done +priority: high +state_hub_task_id: "6636a746-02ca-4a70-ac3c-0219c89cd6a7" +``` + +Use AAL1 for ordinary member sessions. When profile policy or a protected +action requires MFA, restart authorization with AAL2 acr_values and verify the +returned assurance claim before completing the action. + +Done when tegwick can use ordinary login without MFA and opt into or encounter +MFA step-up without affecting another member. + +Implemented explicit `?assurance=aal2`, OIDC `acr_values`, and callback-side +assurance validation. Ordinary login sends no ACR request. + +## T04 - Deploy and run Case A / Case B matrix + +```task +id: CSOC-WP-0003-T04 +status: todo +priority: high +state_hub_task_id: "57bac5f4-fd5d-46ba-92a3-a7bbeb15aa08" +``` + +Test known LLDAP user, new registration, repeated/concurrent callback, +email collision, state replay, disabled identity, local-account coexistence, +password-only login, AAL2 step-up, logout, and rollback on railiance01. + +Done when both requested cases pass with non-secret evidence.