Repository navigation
2026 10 04 oidc groundwork
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: /mcp accepts a JWT from an OIDC issuer beside the server token,
verifies it fully (signature, iss, aud, exp, role) and hands the caller to
the code as a principal; the cluster's selenium-flow sits behind agentgateway and
Claude Code signs in to it the way it signs in to kb.
Architecture: A new top-level oidc settings section. http/auth.py builds
the provider FastMCP's auth= takes: a constant-time ServerTokenVerifier, plus
an OidcVerifier (FastMCP's JWTVerifier with a role check) under MultiAuth.
Both return a PrincipalToken carrying a Principal (new principal.py);
mcp/clients.caller() copies it onto Caller, and session://current shows it.
Nothing else reads it this round.
Tech Stack: Python 3.10+, FastMCP 4 (fastmcp-slim ≥ 4.0.0: MultiAuth,
JWTVerifier, RSAKeyPair, get_access_token), pydantic 2, Starlette,
pytest + pytest-asyncio; kustomize + agentgateway 1.6 CRDs in the cluster repo.
Spec: docs/superpowers/specs/2026-10-04-oidc-groundwork-design.md — read
Rulings, Non-goals and The design before any task.
- No
oidcsetting set ⇒ behaviour identical tomain. Every existing test passes unchanged except where a task says otherwise. - The REST routes, admin API, event stream and signed links stay token-only (
http/auth.authorized). Do not touch them. - Sessions are not renamed and get no owner. Nothing decides anything from a
Principalthis round. - Token comparisons are
hmac.compare_digest. One module decides authorisation:kubed/selenium_flow/http/auth.py. - Every refusal on
/mcpis a 401 (a verifier returningNone), the missing role included. - FastMCP floor stays
fastmcp>=4.0.0,<5. No new runtime dependency. - Real hosts never appear in this repo: examples and tests use
example.com, realmexample, userdrk. Before any push,git grep -ithe diff for the homelab's domain and the maintainer's name (the session knows both; this repo must not) and find nothing. - Copy is terse (no helper paragraphs in UI or errors beyond one sentence). Comments earn their lines: keep the non-obvious why.
- Running tests in the code-server pod: there is no venv. Install the test extra with
pip install --quiet --target $PY -e ".[test]"into a scratch dir, thenPYTHONPATH=$PY:. $PY/bin/pytest -q -p no:randomly <paths>and$PY/bin/ruff check kubed tests/ruff format --check. Never run the whole suite without-xfirst; never run Behat/psalm-like tools (none here). - Commits: one per task, message in the house style (sentence, no prefix tag), ending with the
Co-Authored-Byline the session gives.
| File | Responsibility |
|---|---|
Create kubed/selenium_flow/principal.py
|
Principal, roles_in: who a verified caller is. Pure; imports nothing from FastMCP. |
Modify kubed/selenium_flow/config.py
|
OidcSettings section; oidc_problem(); the post-merge check in load(); URL leaves. |
Modify kubed/selenium_flow/http/auth.py
|
PrincipalToken, ServerTokenVerifier, OidcVerifier, provider(). |
Modify kubed/selenium_flow/server.py
|
auth=http_auth.provider(settings) replaces the StaticTokenVerifier block. |
Modify kubed/selenium_flow/session/sessions.py
|
Caller.principal; describe() shows it. |
Modify kubed/selenium_flow/mcp/clients.py
|
principal(); caller() fills it. |
Create tests/test_principal.py
|
Unit: Principal, roles_in. |
Create tests/test_config_oidc.py
|
The oidc section and the load rules. |
Create tests/jwks.py
|
Test helper: a loopback JWKS server and token minting. |
Modify tests/test_auth.py
|
Verifiers and provider(). |
Create tests/test_oidc_http.py
|
Through the real entry point: /mcp over HTTP. |
| Docs |
README.md, AGENTS.md, CHANGELOG.md, wiki/Configuration.md, wiki/Deployment.md. |
| Cluster repo |
apps/selenium/components/mcp/{gateway.yaml,kustomization.yaml,config.yaml}, .mcp.json, apps/agentgateway/AGENTS.md. |
Files:
- Create:
kubed/selenium_flow/principal.py - Test:
tests/test_principal.py
Interfaces:
-
Produces:
Principal(kind: Literal["admin","oidc"], subject: str|None=None, username: str|None=None, roles: tuple[str,...]=()),.admin -> bool,.status() -> dict,Principal.from_claims(claims: Mapping, roles_claim: str) -> Principal,ADMIN: Principal(the server token's),roles_in(claims: Mapping, roles_claim: str) -> tuple[str, ...]. -
Step 1: Write the failing tests
"""Who a verified caller is: the server token's admin, or an OIDC subject."""
import pytest
from kubed.selenium_flow.principal import ADMIN, Principal, roles_in
pytestmark = pytest.mark.unit
def test_the_server_token_is_the_admin():
assert ADMIN.admin and ADMIN.kind == "admin"
assert ADMIN.status() == {"kind": "admin"}
def test_a_jwt_is_an_oidc_principal_with_its_subject_and_username():
claims = {"sub": "6b0f", "preferred_username": "drk", "roles": ["mcp", "admin"]}
who = Principal.from_claims(claims, "roles")
assert (who.kind, who.subject, who.username, who.roles) == (
"oidc", "6b0f", "drk", ("mcp", "admin"),
)
# A JWT role named admin is not the admin this round (spec, The principal).
assert not who.admin
assert who.status() == {"kind": "oidc", "subject": "6b0f", "username": "drk"}
def test_a_jwt_without_a_username_says_none():
assert Principal.from_claims({"sub": "x"}, "roles").username is None
@pytest.mark.parametrize(
("claims", "path", "held"),
[
({"roles": ["mcp"]}, "roles", ("mcp",)),
({"realm_access": {"roles": ["a", "b"]}}, "realm_access.roles", ("a", "b")),
({}, "roles", ()),
({"roles": "mcp"}, "roles", ()), # a string is not a list
({"roles": ["mcp", 3, None]}, "roles", ("mcp",)), # non-strings dropped
({"realm_access": ["x"]}, "realm_access.roles", ()), # walk hits a list
],
)
def test_roles_in_reads_a_dotted_path_and_nothing_else(claims, path, held):
assert roles_in(claims, path) == held- Step 2: Run them to see them fail
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_principal.py
Expected: FAIL, ModuleNotFoundError: kubed.selenium_flow.principal.
- Step 3: Implement
"""Who a verified caller is.
Two kinds, from the two credentials `/mcp` accepts: the server token, which is
the operator and so the admin, and a JWT from the configured OIDC issuer, which
is a subject. Built by the verifiers in ``http/auth.py`` and carried on
``Caller``; nothing decides anything from it yet (spec 2026-10-04, groundwork).
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal
def roles_in(claims: Mapping[str, Any], roles_claim: str) -> tuple[str, ...]:
"""The roles a token holds: ``roles_claim`` walked by dots, strings only.
The one reading of the claim, shared by the role check and the principal so
they cannot disagree. Anything that is not a list of strings holds none.
"""
value: Any = claims
for part in roles_claim.split("."):
if not isinstance(value, Mapping):
return ()
value = value.get(part)
if not isinstance(value, list):
return ()
return tuple(role for role in value if isinstance(role, str))
@dataclass(frozen=True)
class Principal:
kind: Literal["admin", "oidc"]
subject: str | None = None
username: str | None = None
roles: tuple[str, ...] = ()
@property
def admin(self) -> bool:
return self.kind == "admin"
def status(self) -> dict:
"""What `session://current` shows. Roles are an input, not news."""
if self.admin:
return {"kind": "admin"}
return {"kind": "oidc", "subject": self.subject, "username": self.username}
@classmethod
def from_claims(cls, claims: Mapping[str, Any], roles_claim: str) -> Principal:
sub = claims.get("sub")
name = claims.get("preferred_username")
return cls(
"oidc",
subject=sub if isinstance(sub, str) else None,
username=name if isinstance(name, str) else None,
roles=roles_in(claims, roles_claim),
)
ADMIN = Principal("admin")- Step 4: Run them to see them pass
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_principal.py && $PY/bin/ruff check kubed/selenium_flow/principal.py tests/test_principal.py
Expected: PASS, ruff clean.
- Step 5: Commit
git add kubed/selenium_flow/principal.py tests/test_principal.py
git commit -m "A principal: who a verified caller is, admin or an OIDC subject"Files:
- Modify:
kubed/selenium_flow/config.py(newOidcSettingsafterAuthSettings~line 106;Settings.oidcafterauth~line 297;URL_LEAVES~line 704;oidc_problem()beforeload(); the check at the end ofload()~line 675) - Test:
tests/test_config_oidc.py
Interfaces:
-
Produces:
config.OidcSettings(issuer,audience,jwks_uri:str | None;roles: list[str];roles_claim: str = "roles"),Settings.oidc,config.oidc_problem(settings: Settings) -> str | None(the reason this settings' OIDC cannot run, else None). -
Step 1: Write the failing tests
"""The `oidc` section: a JWT issuer beside the token, never instead of it."""
import pytest
from kubed.selenium_flow import config
from kubed.selenium_flow.config import ConfigError, Settings, load
pytestmark = pytest.mark.unit
ISSUER = "https://auth.example.com/realms/example"
JWKS = ISSUER + "/protocol/openid-connect/certs"
ON = {
"OIDC_ISSUER": ISSUER,
"OIDC_AUDIENCE": "https://mcp.example.com",
"OIDC_JWKS_URI": JWKS,
"AUTH_TOKEN": "t0ken",
}
def test_off_by_default():
oidc = Settings().oidc
assert (oidc.issuer, oidc.audience, oidc.jwks_uri, oidc.roles) == (None, None, None, [])
assert oidc.roles_claim == "roles"
assert config.oidc_problem(Settings()) is None
def test_the_env_names_follow_the_rule():
envs = {leaf.env for leaf in config.leaves() if leaf.section == "oidc"}
assert envs == {
"OIDC_ISSUER", "OIDC_AUDIENCE", "OIDC_JWKS_URI", "OIDC_ROLES", "OIDC_ROLES_CLAIM",
}
def test_roles_are_a_comma_list_from_env():
loaded = load([], ON | {"OIDC_ROLES": "mcp, admin,"})
assert loaded.settings.oidc.roles == ["mcp", "admin"]
def test_all_three_load_with_a_token():
oidc = load([], ON).settings.oidc
assert (oidc.issuer, oidc.jwks_uri) == (ISSUER, JWKS)
@pytest.mark.parametrize("missing", ["OIDC_AUDIENCE", "OIDC_JWKS_URI", "OIDC_ISSUER"])
def test_issuer_audience_and_jwks_are_all_or_nothing(missing):
env = {k: v for k, v in ON.items() if k != missing}
with pytest.raises(ConfigError, match="together"):
load([], env)
def test_oidc_needs_the_token():
env = {k: v for k, v in ON.items() if k != "AUTH_TOKEN"}
with pytest.raises(ConfigError, match="auth.token"):
load([], env)
def test_oidc_in_the_file_and_the_token_in_env_loads(tmp_path):
"""The cluster's shape: `load()` validates the file alone first."""
path = tmp_path / "config.yaml"
path.write_text(
f"oidc:\n issuer: {ISSUER}\n audience: https://mcp.example.com\n"
f" jwks_uri: {JWKS}\n roles: [mcp]\n"
)
loaded = load(["--config-file", str(path)], {"AUTH_TOKEN": "t0ken"})
assert loaded.settings.oidc.roles == ["mcp"]
assert loaded.sources["oidc.issuer"] == "config"
@pytest.mark.parametrize("key", ["OIDC_ISSUER", "OIDC_JWKS_URI"])
def test_the_urls_are_http(key):
with pytest.raises(ConfigError, match="http"):
load([], ON | {key: "ftp://auth.example.com/x"})
def test_oidc_problem_is_the_same_rule_for_settings_built_in_code():
built = Settings(oidc={"issuer": ISSUER, "audience": "a", "jwks_uri": JWKS})
assert "auth.token" in config.oidc_problem(built)- Step 2: Run them to see them fail
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_config_oidc.py
Expected: FAIL (Settings has no oidc).
- Step 3: Implement
After AuthSettings:
class OidcSettings(Section):
# Every cross-field rule lives in oidc_problem(), not a model validator:
# load() validates the config file on its own before env and args merge in,
# and the cluster sets `oidc` in the file with the token in env.
issuer: str | None = Field(
None, description="The issuer a JWT must name; setting it turns OIDC on."
)
audience: str | None = Field(None, description="The audience a JWT must include.")
jwks_uri: str | None = Field(
None, description="Where the issuer publishes its signing keys."
)
# NoDecode, like security.frame_ancestors: comma-separated in env.
roles: Annotated[list[str], NoDecode] = Field(
default_factory=list,
description="A JWT must hold one of these roles; empty checks none.",
)
roles_claim: str = Field(
"roles", description="The claim holding the roles; dots walk into objects."
)
@field_validator("roles", mode="before")
@classmethod
def _split(cls, value):
if isinstance(value, str):
value = value.split(",")
if not isinstance(value, list):
return value
return [
part.strip() if isinstance(part, str) else part
for part in value
if not (isinstance(part, str) and not part.strip())
]
@field_validator("issuer", "jwks_uri")
@classmethod
def _http(cls, value):
if value is not None and not value.startswith(("https://", "http://")):
raise ValueError("must be an http(s) URL")
return valueIn Settings, directly after auth:
oidc: OidcSettings = Field(
default_factory=OidcSettings,
description="Accept a JWT from an OIDC issuer beside the token.",
)URL_LEAVES gains "oidc.issuer" and "oidc.jwks_uri".
Before load():
def oidc_problem(settings: Settings) -> str | None:
"""Why this server's OIDC cannot run, or None. Checked after every layer merges."""
oidc = settings.oidc
named = [oidc.issuer, oidc.audience, oidc.jwks_uri]
if not any(named):
return None
if not all(named):
# An issuer without an audience accepts any token that issuer ever minted.
return "oidc.issuer, oidc.audience and oidc.jwks_uri are set together or not at all"
if not (settings.auth.token and settings.auth.token.get_secret_value()):
# The token is the admin login and the signing key; without it the REST
# and admin routes are open, and OIDC must not be a way to get there.
return "oidc needs auth.token: the token stays the admin login"
return NoneAt the end of load(), after settings = Settings.model_validate(merged) succeeds and before return:
problem = oidc_problem(settings)
if problem:
raise ConfigError(problem)- Step 4: Run the new tests and the config suites
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_config_oidc.py tests/test_config_schema.py tests/test_config_load.py tests/test_admin_settings.py tests/test_config_wiring.py
Expected: PASS. If test_admin_settings.py or a golden lists every section, the new oidc card is the expected difference: update the expectation to include it and say so in the commit message.
- Step 5: Commit
git add kubed/selenium_flow/config.py tests/test_config_oidc.py tests/test_admin_settings.py
git commit -m "An oidc section: issuer, audience, JWKS and roles, and never without the token"Files:
- Modify:
kubed/selenium_flow/http/auth.py - Modify:
kubed/selenium_flow/server.py:14(import) and:123-136(the auth block) - Create:
tests/jwks.py - Modify:
tests/test_auth.py
Interfaces:
-
Consumes:
Principal,ADMIN,roles_in(Task 1);Settings.oidc,config.oidc_problem,config.ConfigError(Task 2). -
Produces:
PrincipalToken(AccessToken)withprincipal: Principal;ServerTokenVerifier(token: str);OidcVerifier(oidc: OidcSettings, *, http_client=None);provider(settings: Settings, *, http_client=None) -> AuthProvider | None(raisesConfigErroronoidc_problem);CLIENT_ID = "selenium-flow". Test helpertests/jwks.py:Issuerwith.issuer,.jwks_uri,.mint(**overrides) -> str,.settings(token, roles=("mcp",)) -> Settings, and a pytest fixtureissuerintests/conftest.py. -
Step 1: Write the test helper
tests/jwks.py:
"""A real OIDC issuer for tests: one RSA key, published over loopback HTTP.
Real HTTP on purpose: the verifier under test fetches `jwks_uri` itself, and a
mocked fetch would test the mock (memory: test the property, not the helper).
"""
from __future__ import annotations
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from fastmcp.server.auth.providers.jwt import RSAKeyPair
from joserfc.jwk import RSAKey
from kubed.selenium_flow.config import Settings
KID = "test-key"
AUDIENCE = "https://mcp.example.com"
class Issuer:
def __init__(self):
self.keys = RSAKeyPair.generate()
jwk = RSAKey.import_key(self.keys.public_key).as_dict(private=False)
body = json.dumps({"keys": [jwk | {"kid": KID, "use": "sig", "alg": "RS256"}]})
class Handler(BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802 - the stdlib's name
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body.encode())
def log_message(self, *args):
pass
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.http.serve_forever, daemon=True).start()
port = self.http.server_address[1]
self.issuer = f"http://127.0.0.1:{port}/realms/example"
self.jwks_uri = f"{self.issuer}/protocol/openid-connect/certs"
def mint(self, *, keys: RSAKeyPair | None = None, **overrides) -> str:
claims = {"preferred_username": "drk", "roles": ["mcp"]}
claims |= overrides.pop("claims", {})
return (keys or self.keys).create_token(
subject=overrides.pop("subject", "6b0f"),
issuer=overrides.pop("issuer", self.issuer),
audience=overrides.pop("audience", AUDIENCE),
expires_in_seconds=overrides.pop("expires_in_seconds", 300),
additional_claims=claims,
kid=KID,
)
def settings(self, token: str, roles=("mcp",)) -> Settings:
return Settings(
grid={"url": "http://grid.invalid:4444"},
auth={"token": token},
oidc={
"issuer": self.issuer,
"audience": AUDIENCE,
"jwks_uri": self.jwks_uri,
"roles": list(roles),
},
)
def close(self):
self.http.shutdown()In tests/conftest.py, beside the server fixtures:
@pytest.fixture
def issuer():
"""A loopback OIDC issuer: its JWKS is fetched for real."""
from .jwks import Issuer
made = Issuer()
yield made
made.close()- Step 2: Write the failing tests (append to
tests/test_auth.py)
# ---- the MCP door -------------------------------------------------------------
from kubed.selenium_flow.config import ConfigError, Settings
from kubed.selenium_flow.principal import ADMIN
from .jwks import AUDIENCE
async def test_the_server_token_verifier_accepts_only_the_exact_token():
verifier = auth.ServerTokenVerifier(TOKEN)
token = await verifier.verify_token(TOKEN)
assert token.principal == ADMIN and token.client_id == auth.CLIENT_ID
for wrong in ("", TOKEN[:-1], TOKEN + "x", "Bearer " + TOKEN, "tökén"):
assert await verifier.verify_token(wrong) is None
async def test_a_good_jwt_becomes_an_oidc_principal(issuer):
verifier = auth.OidcVerifier(issuer.settings(TOKEN).oidc)
token = await verifier.verify_token(issuer.mint())
assert token.principal.kind == "oidc"
assert (token.principal.subject, token.principal.username) == ("6b0f", "drk")
@pytest.mark.parametrize(
"overrides",
[
{"audience": "https://other.example.com"},
{"issuer": "https://auth.example.com/realms/other"},
{"expires_in_seconds": -60},
{"claims": {"roles": ["viewer"]}},
{"claims": {"roles": "mcp"}},
],
ids=["audience", "issuer", "expired", "no-role", "roles-not-a-list"],
)
async def test_a_bad_jwt_is_refused(issuer, overrides):
verifier = auth.OidcVerifier(issuer.settings(TOKEN).oidc)
assert await verifier.verify_token(issuer.mint(**overrides)) is None
async def test_a_jwt_signed_by_another_key_is_refused(issuer):
from fastmcp.server.auth.providers.jwt import RSAKeyPair
verifier = auth.OidcVerifier(issuer.settings(TOKEN).oidc)
assert await verifier.verify_token(issuer.mint(keys=RSAKeyPair.generate())) is None
async def test_no_roles_configured_checks_none(issuer):
verifier = auth.OidcVerifier(issuer.settings(TOKEN, roles=()).oidc)
assert await verifier.verify_token(issuer.mint(claims={"roles": []})) is not None
def test_the_provider_per_configuration(issuer):
assert auth.provider(Settings()) is None
assert isinstance(auth.provider(Settings(auth={"token": TOKEN})), auth.ServerTokenVerifier)
both = auth.provider(issuer.settings(TOKEN))
assert [type(v) for v in both.verifiers] == [auth.ServerTokenVerifier, auth.OidcVerifier]
# No OAuth routes: the gateway and the issuer own discovery.
assert both.get_routes("/mcp") == []
def test_the_provider_refuses_oidc_without_the_token(issuer):
settings = issuer.settings(TOKEN).model_copy(update={"auth": Settings().auth})
with pytest.raises(ConfigError, match="auth.token"):
auth.provider(settings)Replace the two old tests at the bottom of the file, which assert on
server.mcp.auth / open_server.mcp.auth, with:
def test_a_token_turns_on_the_mcp_verifier(server):
assert isinstance(server.mcp.auth, auth.ServerTokenVerifier)
def test_no_token_leaves_the_server_open(open_server):
"""`docker compose up` runs without a token on purpose."""
assert open_server.mcp.auth is None
assert open_server.auth_token is None- Step 3: Run them to see them fail
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_auth.py
Expected: FAIL (auth.ServerTokenVerifier missing).
- Step 4: Implement — append to
http/auth.py(update its module docstring's last paragraph to say it also builds the MCP door's verifiers)
from fastmcp.server.auth import AccessToken, AuthProvider, MultiAuth, TokenVerifier
from fastmcp.server.auth.providers.jwt import JWTVerifier
from .. import config
from ..principal import ADMIN, Principal, roles_in
# The client id the server token reports as. Shown nowhere; FastMCP needs one.
CLIENT_ID = "selenium-flow"
class PrincipalToken(AccessToken):
"""An access token that says who it is, so nothing re-reads its claims."""
principal: Principal
class ServerTokenVerifier(TokenVerifier):
"""The server token on `/mcp`: the admin. Constant-time, unlike a dict lookup."""
def __init__(self, token: str):
super().__init__()
self._token = token
async def verify_token(self, token: str) -> AccessToken | None:
# Bytes, not str: compare_digest raises on a non-ASCII str, and a
# bearer is whatever a client sends.
if not hmac.compare_digest(token.encode(), self._token.encode()):
return None
return PrincipalToken(token=token, client_id=CLIENT_ID, scopes=[], principal=ADMIN)
class OidcVerifier(JWTVerifier):
"""A JWT from the configured issuer: signature, iss, aud, exp, then a role.
No routes: the gateway serves the protected-resource metadata and the issuer
serves the rest. A missing role is a refusal like any other, so a 401.
"""
def __init__(self, oidc: config.OidcSettings, *, http_client=None):
super().__init__(
jwks_uri=oidc.jwks_uri,
issuer=oidc.issuer,
audience=oidc.audience,
algorithm="RS256",
http_client=http_client,
)
self._roles = frozenset(oidc.roles)
self._roles_claim = oidc.roles_claim
async def verify_token(self, token: str) -> AccessToken | None:
verified = await super().verify_token(token)
if verified is None:
return None
claims = verified.claims
if self._roles and not self._roles.intersection(roles_in(claims, self._roles_claim)):
return None
return PrincipalToken(
**verified.model_dump(),
principal=Principal.from_claims(claims, self._roles_claim),
)
def provider(settings: config.Settings, *, http_client=None) -> AuthProvider | None:
"""What `/mcp` checks a bearer with: nothing, the token, or the token then a JWT."""
problem = config.oidc_problem(settings)
if problem:
raise config.ConfigError(problem)
token = settings.auth.token.get_secret_value() if settings.auth.token else None
if not token:
return None
server_token = ServerTokenVerifier(token)
if not settings.oidc.issuer:
return server_token
# The token first: a string compare, where a JWT costs a signature check.
return MultiAuth(
verifiers=[server_token, OidcVerifier(settings.oidc, http_client=http_client)]
)Check the import path of AccessToken/TokenVerifier/MultiAuth against the
installed package (python -c "from fastmcp.server.auth import MultiAuth, TokenVerifier, AccessToken");
if fastmcp.server.auth does not re-export one, import it from
fastmcp.server.auth.auth. Do not import config at module top if it creates
a cycle (config imports nothing from http, so it should not).
In server.py, drop the StaticTokenVerifier import and replace the block at
:123-130 with:
# The token turns on auth for both surfaces; `oidc` adds a JWT beside it
# on /mcp only. Absent, the server is open — correct for a local
# `docker compose up`, and the reason the deployment always sets one.
auth = http_auth.provider(settings)importing from .http import auth as http_auth (check the name is free in
server.py; the existing from .http import access_log, admin, files line is
where it goes).
- Step 5: Run the auth tests, then everything that builds a server
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q -x tests/test_auth.py tests/test_routes.py tests/test_http_answer.py tests/test_files_and_admin.py tests/test_golden.py
Expected: PASS. Then the whole unit suite: PYTHONPATH=$PY:. $PY/bin/pytest -q -x -n 4 — PASS.
- Step 6: Commit
git add kubed/selenium_flow/http/auth.py kubed/selenium_flow/server.py tests/jwks.py tests/conftest.py tests/test_auth.py
git commit -m "/mcp takes the token or a JWT from the oidc issuer, and the token compares in constant time"Files:
- Modify:
kubed/selenium_flow/session/sessions.py(Caller~line 109;describe()~line 284) - Modify:
kubed/selenium_flow/mcp/clients.py(caller()~line 62; newprincipal()) - Create:
tests/test_oidc_http.py - Modify: whichever existing tests assert the whole
describe()dict (find withgrep -rn '"named_by"' tests), adding"principal".
Interfaces:
-
Consumes:
PrincipalToken(Task 3),Principal(Task 1), theissuerfixture (Task 3). -
Produces:
Caller.principal: Principal | None = None;clients.principal() -> Principal | None;session://currentkey"principal"(Principal.status()orNone). -
Step 1: Write the failing tests —
tests/test_oidc_http.py, through the real entry point
"""The MCP door over real HTTP: a JWT beside the token, and who it says you are.
Through `server.mcp.http_app()` with FastMCP's own client, so the bearer
middleware, the verifiers and `get_access_token()` all run as deployed. The
issuer's JWKS is fetched over loopback (tests/jwks.py).
"""
import json
import httpx
import pytest
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from starlette.testclient import TestClient
from kubed.selenium_flow.mcp import mirror
from kubed.selenium_flow.server import SeleniumMCP
from .conftest import TOKEN
INIT = {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "test", "version": "1"},
},
}
ACCEPT = {"Accept": "application/json, text/event-stream"}
@pytest.fixture
def oidc_server(issuer):
return SeleniumMCP(issuer.settings(TOKEN))
def status_of(server, bearer):
with TestClient(server.mcp.http_app()) as client:
return client.post(
"/mcp", json=INIT, headers=ACCEPT | {"Authorization": f"Bearer {bearer}"}
).status_code
def test_a_jwt_opens_the_mcp_door(oidc_server, issuer):
assert status_of(oidc_server, issuer.mint()) == 200
def test_the_token_still_opens_it(oidc_server):
assert status_of(oidc_server, TOKEN) == 200
@pytest.mark.parametrize(
"overrides",
[
{"audience": "https://other.example.com"},
{"issuer": "https://auth.example.com/realms/other"},
{"expires_in_seconds": -60},
{"claims": {"roles": ["viewer"]}},
],
ids=["audience", "issuer", "expired", "no-role"],
)
def test_a_bad_jwt_is_a_401(oidc_server, issuer, overrides):
assert status_of(oidc_server, issuer.mint(**overrides)) == 401
def test_a_jwt_does_not_open_a_rest_route(oidc_server, issuer):
"""The round's boundary: the REST routes stay token-only."""
with TestClient(oidc_server.mcp.http_app()) as client:
answer = client.get(
"/browser/url", headers={"Authorization": f"Bearer {issuer.mint()}"}
)
assert answer.status_code == 401
async def current(server, bearer):
app = server.mcp.http_app()
def factory(**kwargs):
kwargs.pop("auth", None)
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", **kwargs
)
transport = StreamableHttpTransport(
"http://test/mcp",
headers={"Authorization": f"Bearer {bearer}", "X-Session-Key": "desk"},
httpx_client_factory=factory,
)
async with app.router.lifespan_context(app), Client(transport) as client:
result = await client.call_tool(mirror.READ_TOOL, {"uri": "session://current"})
return json.loads(result.content[0].text)
async def test_session_current_names_the_jwt_subject(oidc_server, issuer):
status = await current(oidc_server, issuer.mint())
assert status["principal"] == {"kind": "oidc", "subject": "6b0f", "username": "drk"}
assert status["session"] == "desk"
async def test_session_current_names_the_admin_for_the_token(oidc_server):
assert (await current(oidc_server, TOKEN))["principal"] == {"kind": "admin"}Plus one unit test in tests/test_sessions.py beside the other describe
tests: an open server's caller (Caller.from_request({}, {"x-session-key": ["desk"]}))
describes with "principal": None.
Before writing current(), check how tests/test_resources.py::read unwraps
mirror.READ_TOOL's result and reuse that unwrapping exactly; the
json.loads(result.content[0].text) above is the expected shape, not a promise.
If StreamableHttpTransport's factory signature differs in the installed
fastmcp (McpHttpClientFactory), match it; the requirement is real HTTP
through the ASGI app with the bearer and X-Session-Key headers.
- Step 2: Run them to see them fail
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q tests/test_oidc_http.py
Expected: the status-code tests PASS already (Task 3 wired the door); the two
session_current tests FAIL with KeyError: 'principal'.
- Step 3: Implement
Caller (frozen dataclass), new last field, and a docstring line:
# Who the verified credential says this is (principal.py), or None on an
# open server. Carried, not consulted: nothing decides anything from it yet.
principal: Principal | None = Nonewith from ..principal import Principal at the top of session/sessions.py.
describe(), right after "named_by":
"principal": caller.principal.status() if caller.principal else None,mcp/clients.py:
from dataclasses import replace
from fastmcp.server.dependencies import get_access_token
from ..principal import Principal
def principal() -> Principal | None:
"""Who this request's verified bearer is, or None on an open server."""
try:
token = get_access_token()
except Exception: # noqa: BLE001 - off a request there is no bearer
return None
return getattr(token, "principal", None)and in caller(), the HTTP branch becomes:
return replace(Caller.from_request(*values, client=client), principal=principal())(stdio stays Caller.stdio(client=client): a stdio process has no bearer.)
- Step 4: Run the new tests and the session/resource suites
Run: PYTHONPATH=$PY:. $PY/bin/pytest -q -x tests/test_oidc_http.py tests/test_sessions.py tests/test_resources.py tests/test_site_data_surface.py tests/test_files_and_admin.py
Expected: PASS. Fix every full-dict describe() assertion the grep found by
adding "principal": None (open server) or {"kind": "admin"} (token server).
Then the whole unit suite, -n 4: PASS.
- Step 5: Commit
git add kubed/selenium_flow/session/sessions.py kubed/selenium_flow/mcp/clients.py tests/
git commit -m "session://current says who the bearer is: the admin, or the OIDC subject"Files:
-
Modify:
README.md(the auth section, ~line 265),AGENTS.md(the auth rules ~lines 133-137 and the session rules ~300-313),CHANGELOG.md(## [Unreleased]) -
Modify (submodule):
wiki/Configuration.md,wiki/Deployment.md -
Step 1: README — under the existing token paragraph, add (advertise, don't explain):
Behind an OIDC gateway, `/mcp` also takes your identity provider's JWT. Set the
issuer, and the server checks the signature, audience, expiry and role itself:
OIDC_ISSUER=https://auth.example.com/realms/example
OIDC_AUDIENCE=https://mcp.example.com
OIDC_JWKS_URI=https://auth.example.com/realms/example/protocol/openid-connect/certs
OIDC_ROLES=mcp
The token keeps working beside it, and stays the admin's.-
Step 2: AGENTS.md — in the auth rules, add:
-
http/auth.pyalso builds/mcp's verifier (provider()): the token alone, orMultiAuth[token, OidcVerifier]. A third credential goes there too. - The verifiers return a
PrincipalToken;Caller.principalcarries it. It decides nothing yet — ownership, per-tool roles and the admin UI's OIDC sign-in are the next round's, and the spec lists them. - The REST routes, admin API and signed links are token-only on purpose: the gateway only routes
/mcp. -
oidcwithoutauth.tokenis aConfigError, checked after the layers merge (config.oidc_problem). - Behind agentgateway,
X-Session-Keycrosses and?session=does not.
-
-
Step 3: CHANGELOG (
## [Unreleased], top):
- `/mcp` accepts a JWT from an OIDC issuer beside the token (`oidc.issuer`, `oidc.audience`, `oidc.jwks_uri`, `oidc.roles`), for a server behind an OIDC gateway.
- The token is compared in constant time on `/mcp` too.
- `session://current` says who the caller is: `admin` for the token, or the OIDC subject.-
Step 4: wiki —
Configuration.md: anoidcsection in the same table format as its neighbours (all five keys, env names, defaults; "needsauth.token").Deployment.md: a short "Behind agentgateway" part:backend.auth.passthrough: {}, nomcp.providerfor Keycloak, use theX-Session-Keyheader. Commit insidewiki/, then commit the submodule pointer in the main repo. Push the wiki before the main branch (aTestfailure with no pytest output is an unpushed submodule SHA). -
Step 5: Check and commit
Run the Global Constraints grep (the homelab's domain, the maintainer's name) over the repo and inside wiki/ — expect nothing.
git add README.md AGENTS.md CHANGELOG.md wiki
git commit -m "Docs: OIDC beside the token, and what the principal does not do yet"- Step 1: Full local gate:
ruff check kubed tests,ruff format --check kubed tests, the whole unit suite with-n 4. All green. - Step 2: Push the wiki submodule, then
git push -u origin oidc. - Step 3: PR #54 is open as a draft (2026-10-04). Update its body with the three CHANGELOG lines and mark it ready for review (
gh pr ready 54). - Step 4: Watch
test,ui,package,quality,integration,bench. Answer every Copilot and code-scanning thread withgh(fix, or decline with evidence) until none is open. - Step 5: Build the branch image:
gh workflow run image.yml --ref oidc -f push=true(check the input name in.github/workflows/image.ymlfirst); wait for:oidc.
Already live (2026-10-04, before this round's code):
apps/selenium/components/mcp/gateway.yaml registers selenium-flow at
https://mcp.<domain>/selenium-flow/mcp: AgentgatewayBackend, HTTPRoute
and an AgentgatewayPolicy that validates the Keycloak JWT (Strict, the mcp
role) and then sends the backend the server token from the
selenium-flow-auth Secret (backend.auth.secretRef, key MCP_AUTH_TOKEN).
That is the pattern for any token-protected MCP server; selenium-flow sees the
admin until this task.
Dr K's rule: the cluster repo gets no git from this round. Edit its files and apply them; never stage, commit or push there.
- Step 1:
gateway.yaml— replace the policy'sbackend.auth.secretRefblock withpassthrough: {}and update its comment (the server now verifies the JWT itself, and sees who is calling). - Step 2:
config.yamlgains theoidc:block (real values: the issuer and JWKS URL ingateway.yaml'sjwtAuthentication):
# A Keycloak JWT on /mcp beside the token, handed on by the agent gateway.
oidc:
issuer: https://auth.<domain>/realms/<realm>
audience: https://mcp.<domain>
jwks_uri: https://auth.<domain>/realms/<realm>/protocol/openid-connect/certs
roles:
- mcpCheck the pod can reach the JWKS URL first:
kubectl exec -n flow deploy/selenium-flow -- python -c "import urllib.request;print(urllib.request.urlopen('<jwks url>').status)".
- Step 3:
kustomization.yamlimages:→newTag: oidcfor the live test. - Step 4: Plan, then apply.
kubectl plan apps/selenium: expect the policy, the ConfigMap hash and the image tag. If the Grid node Deployments showreplicas 1 → 0, a browser is live: tell Dr K andend_browserfirst. Thenkubectl up apps/seleniumandkubectl get agentgatewaypolicy -n flow(Accepted/Attached).
- Step 1:
curl -s https://mcp.<domain>/.well-known/oauth-protected-resource/selenium-flow/mcpnames the Keycloak realm;curl -si -X POST https://mcp.<domain>/selenium-flow/mcpis 401 withWWW-Authenticate. - Step 2:
.mcp.json: theselenium-flowentry becomes
"selenium-flow": {
"type": "http",
"url": "https://mcp.<domain>/selenium-flow/mcp",
"headers": {"X-Session-Key": "claudecode"}
}Dr K authenticates it in Claude Code (/mcp → selenium-flow → authenticate; paste the localhost callback into the box, as for kb).
- Step 3: Read
session://current:principal.kind == "oidc", Dr K's username,session == "claudecode". - Step 4: What only shows live: the tool list matches the client (the mirror tools hidden per
clientInfoacross the gateway); a 30 sassertand arun_flowcomplete through the gateway; a screenshot link opens; an MCP Apps view renders where the client supports it. Record each result in the spec's status line. - Step 5: In-cluster: the server token still works on the Service; a JWT missing the
mcprole is 401 there (mint one by requesting a token for a user without the role, or skip with a note if no such user exists). - Step 6: Report to Dr K. After merge (his call):
newTag: mainback in the kustomization andkubectl up apps/selenium(browsers first again). No git in the cluster repo.
The action pages are generated from openapi.yaml, which is itself generated from the live MCP tool schemas — so they describe the server that shipped, not the one someone remembered. Prose belongs in wiki-notes/<tool>.md in the repo.
selenium-flow · MIT
Start here
Guides
Lifecycle
Going places
Doing things
Getting things out
Site data
Console and network