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:
|
# Public client — no secret:
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
OIDC_SCOPES=openid profile email groups
|
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_APPLICATION_ID=coulomb-social
|
||||||
USER_ENGINE_EXPECTED_AUDIENCE=user-engine-portal
|
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-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-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-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-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 |
|
| 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-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-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-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",
|
"site_name": "coulomb.social",
|
||||||
"default_tenant_id": settings.DEFAULT_TENANT_ID,
|
"default_tenant_id": settings.DEFAULT_TENANT_ID,
|
||||||
"oidc_enabled": settings.OIDC_ENABLED,
|
"oidc_enabled": settings.OIDC_ENABLED,
|
||||||
|
"registration_enabled": bool(settings.NETKINGDOM_REGISTRATION_URL),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from authlib.integrations.httpx_client import OAuth2Client
|
from authlib.integrations.httpx_client import OAuth2Client
|
||||||
|
from authlib.jose import JsonWebKey, jwt
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OIDCConfigurationError(RuntimeError):
|
class OIDCConfigurationError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -52,17 +55,18 @@ def _oauth_client() -> OAuth2Client:
|
||||||
return OAuth2Client(**kwargs)
|
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():
|
if not oidc_configured():
|
||||||
raise OIDCConfigurationError("OIDC is not enabled/configured")
|
raise OIDCConfigurationError("OIDC is not enabled/configured")
|
||||||
doc = discovery_document()
|
doc = discovery_document()
|
||||||
auth_endpoint = doc["authorization_endpoint"]
|
auth_endpoint = doc["authorization_endpoint"]
|
||||||
client = _oauth_client()
|
client = _oauth_client()
|
||||||
uri, _ = client.create_authorization_url(
|
parameters = {"state": state, "code_verifier": code_verifier}
|
||||||
auth_endpoint,
|
if acr_values:
|
||||||
state=state,
|
parameters["acr_values"] = acr_values
|
||||||
code_verifier=code_verifier,
|
uri, _ = client.create_authorization_url(auth_endpoint, **parameters)
|
||||||
)
|
|
||||||
return uri
|
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]:
|
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()
|
doc = discovery_document()
|
||||||
userinfo_endpoint = doc.get("userinfo_endpoint")
|
userinfo_endpoint = doc.get("userinfo_endpoint")
|
||||||
if not userinfo_endpoint:
|
if not userinfo_endpoint:
|
||||||
return {}
|
return {}
|
||||||
resp = httpx.get(
|
try:
|
||||||
userinfo_endpoint,
|
resp = httpx.get(
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
userinfo_endpoint,
|
||||||
timeout=15.0,
|
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()
|
claims.validate()
|
||||||
return resp.json()
|
return dict(claims)
|
||||||
|
|
||||||
|
|
||||||
def new_pkce_pair() -> tuple[str, str]:
|
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]:
|
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] = {}
|
claims: dict[str, Any] = {}
|
||||||
# authlib may leave id_token as JWT string; userinfo is preferred when available
|
id_token = token.get("id_token")
|
||||||
claims.update(userinfo or {})
|
if isinstance(id_token, str) and id_token:
|
||||||
if not claims.get("sub") and isinstance(token.get("userinfo"), dict):
|
claims.update(decode_id_token(id_token))
|
||||||
|
elif isinstance(token.get("userinfo"), dict):
|
||||||
claims.update(token["userinfo"])
|
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
|
return claims
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ app_name = "identity"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login/", views.login_start, name="login"),
|
path("login/", views.login_start, name="login"),
|
||||||
|
path("register/", views.registration_start, name="register"),
|
||||||
path("callback/", views.oidc_callback, name="callback"),
|
path("callback/", views.oidc_callback, name="callback"),
|
||||||
path("logout/", views.logout_view, name="logout"),
|
path("logout/", views.logout_view, name="logout"),
|
||||||
path("dev-login/", views.dev_login, name="dev_login"),
|
path("dev-login/", views.dev_login, name="dev_login"),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SESSION_OIDC_STATE = "oidc_state"
|
SESSION_OIDC_STATE = "oidc_state"
|
||||||
SESSION_OIDC_VERIFIER = "oidc_code_verifier"
|
SESSION_OIDC_VERIFIER = "oidc_code_verifier"
|
||||||
|
SESSION_OIDC_REQUIRED_ACR = "oidc_required_acr"
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
@require_GET
|
||||||
|
|
@ -31,8 +32,19 @@ def login_start(request: HttpRequest) -> HttpResponse:
|
||||||
state, verifier = oidc.new_pkce_pair()
|
state, verifier = oidc.new_pkce_pair()
|
||||||
request.session[SESSION_OIDC_STATE] = state
|
request.session[SESSION_OIDC_STATE] = state
|
||||||
request.session[SESSION_OIDC_VERIFIER] = verifier
|
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:
|
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:
|
except Exception:
|
||||||
logger.exception("OIDC authorization URL build failed")
|
logger.exception("OIDC authorization URL build failed")
|
||||||
messages.error(request, "Identity provider is unavailable. Try again later.")
|
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")
|
state = request.GET.get("state")
|
||||||
expected_state = request.session.pop(SESSION_OIDC_STATE, None)
|
expected_state = request.session.pop(SESSION_OIDC_STATE, None)
|
||||||
verifier = request.session.pop(SESSION_OIDC_VERIFIER, 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:
|
if not code or not state or state != expected_state or not verifier:
|
||||||
return HttpResponseBadRequest("Invalid OIDC callback state")
|
return HttpResponseBadRequest("Invalid OIDC callback state")
|
||||||
|
|
||||||
|
|
@ -77,11 +90,34 @@ def oidc_callback(request: HttpRequest) -> HttpResponse:
|
||||||
return HttpResponseBadRequest("Token missing subject")
|
return HttpResponseBadRequest("Token missing subject")
|
||||||
|
|
||||||
issuer = raw.get("iss") or settings.OIDC_ISSUER
|
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))
|
claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub))
|
||||||
establish_session(request, claims)
|
establish_session(request, claims)
|
||||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
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"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def dev_login(request: HttpRequest) -> HttpResponse:
|
def dev_login(request: HttpRequest) -> HttpResponse:
|
||||||
"""Local-only claims form when OIDC is off. Never enable outside DEBUG."""
|
"""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_REDIRECT_URI = config("OIDC_REDIRECT_URI", default="")
|
||||||
OIDC_SCOPES = config("OIDC_SCOPES", default="openid profile email groups")
|
OIDC_SCOPES = config("OIDC_SCOPES", default="openid profile email groups")
|
||||||
OIDC_DISCOVERY_URL = config("OIDC_DISCOVERY_URL", default="") # optional override
|
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 HTTP (empty base or secret → offline stub)
|
||||||
USER_ENGINE_BASE_URL = config("USER_ENGINE_BASE_URL", default="")
|
USER_ENGINE_BASE_URL = config("USER_ENGINE_BASE_URL", default="")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="btn" href="{% url 'identity:login' %}">Sign in</a>
|
<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>
|
||||||
<p class="muted" style="margin-top: 2rem; font-size: 0.85rem;">
|
<p class="muted" style="margin-top: 2rem; font-size: 0.85rem;">
|
||||||
Tenant: {{ default_tenant_id }} · OIDC:
|
Tenant: {{ default_tenant_id }} · OIDC:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,30 @@
|
||||||
# Deploy notes (stub — CSOC-WP-0002-T08)
|
# Deploy notes
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
Standalone service: commit-SHA images → registry
|
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`).
|
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)
|
## Runtime secrets (names only)
|
||||||
|
|
||||||
K8s Secret `coulomb-social-env` in namespace `coulomb-social` (chart `envFrom`):
|
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
|
./scripts/create-env-secret.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Script: `railiance-apps/tools/create-coulomb-social-env-secret.sh`
|
|
||||||
OIDC is a **public** client — no client secret.
|
OIDC is a **public** client — no client secret.
|
||||||
|
|
||||||
|
Non-secret env (OIDC, ALLOWED_HOSTS, user-engine URL) lives in Helm values.
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
|
|
||||||
- `GET /healthz` → `{"status":"ok"}`
|
- `GET /healthz` → `{"status":"ok","service":"coulomb-social"}`
|
||||||
|
- Probes use `Host: coulomb.social` (`probes.hostHeader`)
|
||||||
|
|
||||||
## Build
|
## Build / deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
docker build -t forgejo.coulomb.social/coulomb/coulomb-social:$SHA .
|
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`,
|
## Cutover checklist (DNS → live Railiance)
|
||||||
`OIDC_*`, `USER_ENGINE_*`, `DEFAULT_TENANT_ID`, `ALLOWED_HOSTS`.
|
|
||||||
|
|
||||||
## Status
|
**Goal:** `https://coulomb.social` serves this app (identity shell), not Bubble.
|
||||||
|
|
||||||
- Dockerfile present (gunicorn, non-root, `/healthz` check).
|
### Preconditions
|
||||||
- railiance-apps Helm values / cluster Service **not** yet landed.
|
|
||||||
- Local `make run` + `make test` remain the default verification path.
|
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
|
# 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`
|
## Offline (dev claims) — **passed**
|
||||||
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)
|
|
||||||
|
|
||||||
## 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
|
| Step | Result |
|
||||||
2. Register redirect URI at the issuer (no wildcards)
|
|------|--------|
|
||||||
3. `/auth/login/` redirects to IdP; callback creates/links Member
|
| Open `/` → **Sign in** | → `/auth/dev-login/` |
|
||||||
4. Logout clears app session
|
| 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
|
## Automated
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test
|
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
|
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
|
@pytest.mark.django_db
|
||||||
def test_app_home_requires_login(client):
|
def test_app_home_requires_login(client):
|
||||||
r = client.get(reverse("core:app_home"))
|
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: `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
|
## 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