Complete identity smoke path: id_token claims, registration entry, cutover docs

Prefer verified KeyCape id_token claims when /userinfo returns 401; soft-fail
userinfo. Add CSOC-WP-0003 registration entry (disabled until NetKingdom URL),
AAL step-up hooks, smoke/cutover evidence for tegwick OIDC without MFA.
This commit is contained in:
tegwick 2026-08-09 22:42:51 +02:00
parent 3bc16b581b
commit 29a9ff735e
14 changed files with 513 additions and 41 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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),
}

View file

@ -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

View file

@ -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"),

View file

@ -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."""

View file

@ -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="")

View file

@ -8,6 +8,9 @@
</p>
<p>
<a class="btn" href="{% url 'identity:login' %}">Sign in</a>
{% if registration_enabled %}
<a class="btn secondary" href="{% url 'identity:register' %}">Create account</a>
{% endif %}
</p>
<p class="muted" style="margin-top: 2rem; font-size: 0.85rem;">
Tenant: {{ default_tenant_id }} · OIDC:

View file

@ -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
```

View file

@ -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`.

110
tests/test_oidc_claims.py Normal file
View file

@ -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

View file

@ -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"))

View file

@ -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

View file

@ -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.