Skip to content

Commit d68f96a

Browse files
committed
chore: checking in
1 parent 18128e7 commit d68f96a

File tree

22 files changed

+733
-317
lines changed

22 files changed

+733
-317
lines changed

playground/cli/deps.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import os
1313
from pathlib import Path
1414
from typing import Any, Dict, Optional
15+
import subprocess
16+
import requests
1517

1618
from .config import ConfigState
1719
from .db.base import DatabaseConfig, DatabaseRoot
@@ -27,6 +29,7 @@ class Deps:
2729
_db: Optional[DatabaseRoot] = None
2830
_k8s_loaded: bool = False
2931
_k8s_clients: Dict[str, Any] | None = None
32+
_http_session: Optional[requests.Session] = None
3033

3134
@property
3235
def runner(self) -> CommandRunner:
@@ -106,3 +109,35 @@ def k8s(self) -> Dict[str, Any]:
106109
}
107110
self._k8s_loaded = True
108111
return self._k8s_clients or {}
112+
113+
@property
114+
def requests_session(self) -> requests.Session:
115+
"""Return a requests Session configured to trust the local mkcert root CA.
116+
117+
We assume the repository generates and stores the CA at `.certs/rootCA.pem`.
118+
This path is resolved relative to the repository root inferred from the
119+
known config path at `playground/config.cue`.
120+
"""
121+
if self._http_session is None:
122+
# Derive repo root from the config path (…/playground/config.cue)
123+
cfg_path = self.state.path.resolve()
124+
repo_root = cfg_path.parent.parent
125+
ca_path = repo_root / ".certs/rootCA.pem"
126+
127+
sess = requests.Session()
128+
if ca_path.exists():
129+
sess.verify = str(ca_path)
130+
else:
131+
# Fallback: try mkcert -CAROOT/rootCA.pem
132+
try:
133+
out = subprocess.run(
134+
["mkcert", "-CAROOT"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
135+
)
136+
ca_dir = Path((out.stdout or "").strip())
137+
alt = ca_dir / "rootCA.pem"
138+
if alt.exists():
139+
sess.verify = str(alt)
140+
except Exception:
141+
pass
142+
self._http_session = sess
143+
return self._http_session

playground/cli/setup/hydra.py

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
import subprocess
66
import time
77
from typing import Mapping as TypingMapping
8+
from datetime import datetime, timedelta, timezone
9+
import tempfile
10+
from pathlib import Path
11+
from kubernetes.client import V1Secret, V1ObjectMeta # type: ignore
12+
from kubernetes.client.exceptions import ApiException # type: ignore
813

914
import requests # type: ignore[import-not-found,import-untyped]
1015

@@ -34,14 +39,17 @@ class HydraClientConfig(BaseModel):
3439

3540
class HydraTaskConfig(BaseModel):
3641
clients: dict[str, HydraClientConfig] = Field(default_factory=dict)
42+
trusted_jwt_grant_issuers: list[dict] = Field(default_factory=list)
3743

3844

3945
class HydraSetup(SetupTask):
4046
"""Register/update Hydra OAuth2 clients based on typed config."""
4147

42-
def __init__(self, clients: TypingMapping[str, HydraClientConfig], runner) -> None:
48+
def __init__(self, clients: TypingMapping[str, HydraClientConfig], runner, trusted: list[dict], deps) -> None:
4349
self.clients = dict(clients)
4450
self.runner = runner
51+
self.trusted = list(trusted)
52+
self.deps = deps
4553

4654
def _port_forward(self) -> subprocess.Popen[bytes]:
4755
return subprocess.Popen(
@@ -68,6 +76,30 @@ def run(self) -> None:
6876

6977
url_base = "http://127.0.0.1:8445/admin/clients"
7078
headers = {"content-type": "application/json"}
79+
80+
# Ensure a persistent signing key secret exists for mock-oidc
81+
secret_ns = "oidc"
82+
secret_name = "mock-oidc-github-signing"
83+
core = self.deps.k8s["core"]
84+
85+
try:
86+
core.read_namespaced_secret(name=secret_name, namespace=secret_ns)
87+
log(f"Signing key secret exists: {secret_ns}/{secret_name}")
88+
except ApiException as e: # pragma: no cover
89+
if getattr(e, "status", None) != 404:
90+
raise
91+
with tempfile.TemporaryDirectory() as td:
92+
key_path = Path(td) / "signing_key.pem"
93+
self.runner.run(["openssl", "genrsa", "-out", str(key_path), "2048"])
94+
pem = key_path.read_text()
95+
sec = V1Secret(
96+
metadata=V1ObjectMeta(name=secret_name),
97+
type="Opaque",
98+
string_data={"signing_key.pem": pem},
99+
)
100+
core.create_namespaced_secret(namespace=secret_ns, body=sec)
101+
log(f"Created signing key secret: {secret_ns}/{secret_name}")
102+
71103
for key, spec in self.clients.items():
72104
client_id = spec.client_id or key
73105
resp = requests.post(url_base, json=spec.model_dump(), headers=headers)
@@ -81,6 +113,92 @@ def run(self) -> None:
81113
)
82114
raise SystemExit(1)
83115
log(f"Hydra client upserted: {client_id}")
116+
117+
# Configure trusted JWT grant issuers (RFC 7523) for JWT-bearer assertions
118+
for entry in self.trusted:
119+
issuer = entry.get("issuer")
120+
jwks_uri = entry.get("jwks_uri") or entry.get("jwks_url")
121+
allow_any_subject = bool(entry.get("allow_any_subject", True))
122+
scope = entry.get("scope") # optional
123+
subject = entry.get("subject") # optional
124+
expires_at = entry.get("expires_at") # optional RFC3339 string
125+
jwks_kid = entry.get("jwks_kid") # optional: filter a specific key
126+
if not issuer or not jwks_uri:
127+
err("trusted_jwt_grant_issuers entry missing 'issuer' or 'jwks_uri'")
128+
raise SystemExit(1)
129+
# Fetch JWKS and create one trust record per key (or the selected kid)
130+
try:
131+
sess = self.deps.requests_session
132+
jwks_resp = sess.get(jwks_uri, timeout=10)
133+
jwks = jwks_resp.json()
134+
keys = jwks.get("keys", []) if isinstance(jwks, dict) else []
135+
except Exception as e: # pragma: no cover
136+
err(f"Failed to fetch JWKS from {jwks_uri}: {e}")
137+
raise SystemExit(1)
138+
139+
if not keys:
140+
err(f"JWKS from {jwks_uri} contained no keys")
141+
raise SystemExit(1)
142+
143+
created = 0
144+
for jwk in keys:
145+
kid = jwk.get("kid")
146+
if jwks_kid and kid != jwks_kid:
147+
continue
148+
149+
payload = {
150+
"issuer": issuer,
151+
"jwk": jwk,
152+
"allow_any_subject": allow_any_subject,
153+
}
154+
if scope:
155+
payload["scope"] = scope
156+
if subject:
157+
payload["subject"] = subject
158+
if not expires_at:
159+
one_year = datetime.now(timezone.utc) + timedelta(days=365)
160+
expires_at = one_year.strftime("%Y-%m-%dT%H:%M:%SZ")
161+
payload["expires_at"] = expires_at
162+
163+
resp = requests.post(
164+
"http://127.0.0.1:8445/admin/trust/grants/jwt-bearer/issuers",
165+
json=payload,
166+
headers=headers,
167+
)
168+
# Accept 201 Created or 409 Conflict (already exists for this kid)
169+
if resp.status_code not in (201, 409):
170+
err(
171+
f"Failed to trust issuer '{issuer}' (kid={kid}): {resp.status_code} {resp.text}"
172+
)
173+
raise SystemExit(1)
174+
created += 1
175+
if created == 0 and jwks_kid:
176+
err(f"No JWKS key with kid={jwks_kid} found at {jwks_uri}")
177+
raise SystemExit(1)
178+
log(f"Trusted JWT grant issuer configured: {issuer} (keys={created})")
179+
180+
# Verification: list trusted issuers from Hydra and log summaries
181+
try:
182+
resp = requests.get(
183+
"http://127.0.0.1:8445/admin/trust/grants/jwt-bearer/issuers",
184+
headers=headers,
185+
timeout=10,
186+
)
187+
if resp.status_code == 200:
188+
items = resp.json()
189+
count = len(items) if isinstance(items, list) else 0
190+
log(f"Hydra trusted JWT issuers: {count}")
191+
if isinstance(items, list):
192+
for it in items:
193+
iss = it.get("issuer")
194+
subj = it.get("subject")
195+
anysub = it.get("allow_any_subject")
196+
kid = (it.get("public_key") or {}).get("kid") if isinstance(it.get("public_key"), dict) else None
197+
log(f" - issuer={iss} subject={subj} allow_any_subject={anysub} kid={kid}")
198+
else:
199+
err(f"Failed to list trusted issuers: {resp.status_code} {resp.text}")
200+
except Exception as e:
201+
err(f"List trusted issuers error: {e}")
84202
finally:
85203
try:
86204
os.kill(pf.pid, signal.SIGTERM)
@@ -92,14 +210,14 @@ def run(self) -> None:
92210
name="hydra",
93211
description="Register/update Hydra OAuth2 clients from tasks.hydra",
94212
priority=50,
95-
dependencies=("runner",),
213+
dependencies=("runner", "k8s"),
96214
)
97215
def _factory(ctx: ConfigState, deps) -> HydraSetup | None:
98216
raw_tasks = (ctx.raw or {}).get("tasks", {})
99217
raw_hydra = raw_tasks.get("hydra") if isinstance(raw_tasks, dict) else None
100218
if not raw_hydra:
101219
return None
102220
typed = HydraTaskConfig.model_validate(raw_hydra)
103-
if not typed.clients:
221+
if not typed.clients and not typed.trusted_jwt_grant_issuers:
104222
return None
105-
return HydraSetup(typed.clients, deps.runner)
223+
return HydraSetup(typed.clients, deps.runner, typed.trusted_jwt_grant_issuers, deps)

playground/config.cue

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ deployments: {
8787
name: "\(registry)/\(deployments.oidc.image.name)"
8888
tag: deployments.oidc.image.tag
8989
}
90+
mounts: {
91+
key: {
92+
ref: secret: name: "mock-oidc-github-signing"
93+
path: "/keys/signing.pem"
94+
subPath: "signing_key.pem"
95+
}
96+
}
9097
}
9198
#config: {
9299
base_url: "https://oidc.projectcatalyst.dev"
@@ -114,6 +121,26 @@ deployments: {
114121
}
115122
}
116123
},
124+
{
125+
id: "github"
126+
public: true
127+
override: false
128+
signing_key_pem_path: "/keys/signing.pem"
129+
issuer_override: "https://token.actions.githubusercontent.com"
130+
default_audience: ["https://auth.projectcatalyst.dev/hydra/public/oauth2/token"]
131+
personas: {
132+
default: {
133+
sub: "repo:acme/repo:ref:refs/heads/main"
134+
claims: {
135+
repository: "acme/repo"
136+
ref: "refs/heads/main"
137+
sha: "deadbeef"
138+
actor: "runner"
139+
environment: "dev"
140+
}
141+
}
142+
}
143+
},
117144
]
118145
}
119146
dns: {
@@ -194,6 +221,24 @@ tasks: {
194221
"http://127.0.0.1:49152/logout",
195222
]
196223
}
224+
gha_ci: {
225+
client_id: "gha-ci"
226+
client_name: "GitHub Actions (dev)"
227+
scope: "openid"
228+
grant_types: ["urn:ietf:params:oauth:grant-type:jwt-bearer"]
229+
token_endpoint_auth_method: "none"
230+
redirect_uris: []
231+
audience: [
232+
"https://forge.projectcatalyst.dev/api",
233+
]
234+
}
197235
}
236+
trusted_jwt_grant_issuers: [
237+
{
238+
issuer: "https://token.actions.githubusercontent.com"
239+
jwks_uri: "https://oidc.projectcatalyst.dev/github/jwks"
240+
allow_any_subject: true
241+
},
242+
]
198243
}
199244
}

playground/helmfile/helmfile.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,13 @@ releases:
398398
args: ["apply", "-f", "platform/envoy/hydra-routes.yaml"]
399399
showlogs: true
400400
- events: ["postsync"]
401-
command: kubectl
402-
args: ["create", "namespace", "oidc"]
401+
command: sh
402+
args:
403+
- -c
404+
- |
405+
set -euo pipefail
406+
kubectl create namespace oidc --dry-run=client -o yaml | kubectl apply -f -
403407
showlogs: true
404-
# Oathkeeper is now deployed as a Helm release below
405408
- name: oathkeeper
406409
namespace: auth
407410
chart: ory/oathkeeper

playground/helmfile/platform/mock-oidc/deployment.yaml

Lines changed: 0 additions & 42 deletions
This file was deleted.

playground/helmfile/platform/mock-oidc/namespace.yaml

Lines changed: 0 additions & 4 deletions
This file was deleted.

playground/helmfile/platform/mock-oidc/service.yaml

Lines changed: 0 additions & 14 deletions
This file was deleted.

playground/helmfile/platform/oathkeeper/values.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ oathkeeper:
2828
config:
2929
headers:
3030
X-Subject: "{{ print .Subject }}"
31-
X-Email: "{{ .Extra.ext.email }}"
31+
# Render email only if present to avoid template errors when ext is missing
32+
X-Email: "{{ with .Extra.ext }}{{ .email }}{{ end }}"
3233

3334
deployment:
3435
extraVolumes:

playground/helmfile/platform/ory/hydra-values.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ hydra:
3737
access_token: jwt
3838
scope: exact
3939
ttl:
40-
access_token: 30m
40+
access_token: 1h
4141
id_token: 30m
42-
refresh_token: 720h
42+
refresh_token: 8h
4343

4444
deployment:
4545
extraEnv:

0 commit comments

Comments
 (0)