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:
parent
3bc16b581b
commit
29a9ff735e
14 changed files with 513 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
110
tests/test_oidc_claims.py
Normal 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
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
workplans/CSOC-WP-0003-self-registration-and-assurance.md
Normal file
96
workplans/CSOC-WP-0003-self-registration-and-assurance.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue