2026 10 04 oidc groundwork design - kubed-io/selenium-flow GitHub Wiki
Status: DESIGNED 2026-10-04 on branch oidc; not yet built. Plan:
docs/superpowers/plans/2026-10-04-oidc-groundwork.md. Dr K reads this as it
grows and drops rulings in chat; every ruling is recorded under Rulings.
Dr K, 2026-10-04: the next big thing is OIDC. An MCP gateway (agentgateway) now sits in front of the homelab's MCP servers, validating Keycloak tokens, and mcp-kb is already behind it. Research FastMCP's OIDC support and the gateway, and find the best strategy. His questions, answered in Research:
- Would FastMCP's OIDC integration work with the gateway, or are they alternative routes?
- If FastMCP's OIDC were fully configured, would the gateway not be doing the same thing?
- With the gateway doing OIDC, is it disabled on FastMCP, or do they work together?
- Even without FastMCP's OIDC, is there functionality it could unlock?
Then, narrowing the round: "focus on the groundwork to enable multi-user with
oidc and not yet try and fully comprehend what that means yet." One user uses
this. The admin UI is left alone, with the door open. The bare minimum on the
MCP protocol: accept and verify the JWT. Put the cluster's selenium-flow behind
the gateway like mcp-kb. Done means the cluster's .mcp.json signs in to
selenium-flow through the gateway exactly as it does to kb.
FastMCP's "OIDC" is three different things (fastmcp 4.0.10 source,
fastmcp/server/auth/):
| Piece | What it does | Beside a gateway |
|---|---|---|
OAuthProxy / OIDCProxy
|
FastMCP fronts the authorization server | An alternative route: competes with the gateway |
RemoteAuthProvider / KeycloakAuthProvider
|
Serves RFC 9728 protected-resource metadata, verifies | Duplicates what the gateway serves |
JWTVerifier |
Signature, iss, aud, exp against a JWKS; no routes
|
Complements it: the piece used here |
MultiAuth(verifiers=[...]) tries each verifier in turn, first success wins, and
contributes no routes when it has no server. AccessToken.claims holds the
whole JWT; get_access_token() reads it inside a request. All of this exists
from fastmcp-slim 4.0.0, so the fastmcp>=4.0.0,<5 floor does not move.
The gateway strips the token after validating it (agentgateway 1.6,
jwtAuthentication: "By default, the gateway removes the token after
validation"). So a server behind it today knows nothing about the caller.
backend.auth.passthrough: {} puts the validated token back on the request to
the backend only; the docs prefer it to preserveToken: true, which exposes the
token to every later policy.
The MCP spec puts the audience check on the server in every revision through 2026-07-28: "MCP servers MUST validate that access tokens were issued specifically for them as the intended audience". Once the gateway forwards the token, selenium-flow is the server that accepts it, so it validates it.
Two doors bypass the gateway. Pods reach selenium-flow through the
in-cluster Service, and the ingress routes selenium.<domain>/flow/* here with
the prefix stripped, so /flow/mcp is a second external MCP door without the
gateway's rate limits or audit. That is why the identity is a forwarded JWT the
server verifies, not a x-user: jwt.sub header the gateway injects: a header is
forgeable by any pod, a signature is not. It is also why the gateway's role
check is repeated here. The server's own checks are the same on every door.
In the homelab the ingress door is closed (Dr K, 2026-10-04): a Traefik route
answers 403 to /flow/mcp in every spelling Starlette would serve (//mcp,
%6Dcp), on http and https, so the gateway is the only external MCP door. The
in-cluster Service remains, for clients holding the token.
What crosses the gateway (agentgateway crates/agentgateway/src/mcp/upstream/ mod.rs, IncomingRequestContext::apply, read 2026-10-04): every client header is
copied to the backend request except Mcp-Session-Id, Content-Length and
Content-Encoding. The query string is not: the upstream URI is the target's
fixed host and path. So X-Session-Key reaches selenium-flow through the
gateway and ?session= does not.
The token the gateway accepts (the homelab's Keycloak mcp client scope):
aud holds the MCP audience shared by every path; roles is a flat list of the
gateway client's roles (mcp, admin); preferred_username, email and
groups ride along. The gateway allows a route on
jwt.roles.exists(r, r == "mcp").
So, Dr K's questions: they are layers, not alternatives. The gateway answers
may you come in β discovery, the 401 challenge, the role, rate limits, audit.
selenium-flow answers who is this, by verifying the same token with a bare
JWTVerifier. Nothing is disabled on either side, and FastMCP serves no OAuth
routes. What it unlocks, later: workspaces with owners, per-tool roles that also
filter tools/list, an admin UI that signs in with Keycloak, an audit trail of
who drove which browser. This round lays the wire for those and builds none.
Dr K, 2026-10-04, in order:
- The gateway at the edge, selenium-flow verifies the forwarded token β the recommended of three approaches (the others: gateway only, as mcp-kb does; FastMCP owns OIDC and the gateway goes).
- The token stays, as an option, for good. n8n keeps using headers. The token is the admin login, with or without OIDC, and stays the HMAC key for signed links. "We have the admin token as bearer auth to simply be admin if someone wanted to and has that token."
- The admin UI is left alone. When it grows OIDC, a "sign in with OIDC" option sits below the token field; the token never goes away.
-
sessionnames are a project or workspace, not an identity β context and focus, like "the Grafana dashboards". Loose and global. Not changed this round. An agent that needs two registers the server twice. - One user, groundwork only. No multi-user product: no ownership, no per-user views. "Wired up with the jwt stuff", and no extra functionality.
- Roles are checked this round (Dr K took the recommendation): the in-cluster door must not accept a token the gateway would refuse.
- The route prefix is already configurable, so where the gateway mounts this server is a deploy choice, not a design one.
The MCP endpoint accepts a Keycloak JWT beside the server token, verifies it fully (signature, issuer, audience, expiry, role), and hands the verified caller to the code as a principal. The cluster's selenium-flow is reachable through the gateway with OAuth, and Claude Code signs in to it the way it signs in to kb.
- No change to sessions: no rename, no owner, no per-subject anything.
- No change to the admin UI, the REST routes (
/browser/*,/files*,/flows*,/site-data*,/secrets), the admin API, signed links,/readyor/info. They stay token-only. The gateway only routes/mcp. - No OAuth routes in selenium-flow: no protected-resource metadata, no authorization-server proxy, no DCR. The gateway and Keycloak own discovery.
- No per-tool authorization, no admin role from a JWT, no audit log of principals. All of it is next round's, once there is a principal to use.
- No token exchange. selenium-flow never forwards the caller's token anywhere.
- Every rule in
AGENTS.mdstays. In particular: one place decides whether a request is authorised (http/auth.py), and the token comparison ishmac.compare_digest. - No
oidcsettings means the same auth behaviour as before; the one published change isprincipal, insession://currentandGET /browser. - No backwards compatibility to preserve (one user), but nothing here breaks a
shape anyway: the only published change is one new key,
principal. - The repo names no real host: examples use
example.comand the realmexample.
Claude Code ββOAuth (DCR, PKCE)βββΆ Keycloak
β Authorization: Bearer <JWT>, X-Session-Key: <name>
βΌ
agentgateway protected-resource metadata, 401 challenge,
β iss/aud/exp + role `mcp` backend.auth.passthrough: {}
βΌ
selenium-flow /mcp: MultiAuth[ server token | JWTVerifier + role ]
ββ principal β Caller β session://current
A setting's path is section.key (two levels; config.leaves() walks no
deeper), so the group is a top-level section, not auth.oidc. That also leaves
room for the admin UI's later oidc.client_id.
| Path | Env | Type | Default | Meaning |
|---|---|---|---|---|
oidc.issuer |
OIDC_ISSUER |
str | unset | The iss a token must carry. Setting it turns OIDC on. |
oidc.audience |
OIDC_AUDIENCE |
str | unset | The aud a token must include. |
oidc.jwks_uri |
OIDC_JWKS_URI |
str | unset | Where the issuer publishes its signing keys. |
oidc.roles |
OIDC_ROLES |
list, comma-separated in env | [] |
A token must hold at least one. Empty checks none. |
oidc.roles_claim |
OIDC_ROLES_CLAIM |
str | roles |
Where the roles are; a dotted path walks objects (realm_access.roles). |
Section description: "Accept a JWT from an OIDC issuer beside the token."
Rules, each a ConfigError that stops the boot:
-
issuer,audienceandjwks_uriare set together or not at all. An issuer without an audience would accept any token that issuer ever minted. -
issuerandjwks_uriarehttp(s)URLs. -
oidcneedsauth.token. The token is the admin login and the signing key; without it the REST and admin routes are open (unset is open), which is not a state OIDC should be able to produce. Checked after the layers merge, not in the model: the cluster setsoidcin the file and the token in env, andload()validates the file on its own first.
jwks_uri is required rather than derived: Keycloak's is
{issuer}/protocol/openid-connect/certs, other issuers differ, and discovering
it at boot adds a network dependency to startup for one string the operator
already knows.
http/auth.py builds the provider FastMCP's auth= takes, so the one place
that decides authorisation keeps deciding it for /mcp too:
-
No token, no
oidc:None. Open, as today. -
Token only:
ServerTokenVerifieralone. It replaces FastMCP'sStaticTokenVerifier, whosedict.getis not a constant-time comparison; the new one useshmac.compare_digest, like every other door already does. -
Token and
oidc:MultiAuth(verifiers=[ServerTokenVerifier, OidcVerifier]). The token is tried first: it is a constant-time string compare, where the JWT check parses and verifies a signature.
OidcVerifier is FastMCP's JWTVerifier (issuer, audience, jwks_uri,
RS256) plus the role check: after the parent accepts a token, it reads
roles_claim from the claims, and refuses the token unless it holds one of
roles. A missing claim, or one that is not a list of strings, holds no roles.
Every refusal is a 401, the role included. A verifier that returns None is
FastMCP's invalid_token. A 403 insufficient_scope would be the more precise
answer for a missing role, but FastMCP's 403 path is for scopes, and the gateway
already refuses that token before it gets here; only a caller on a door that
bypasses the gateway (the in-cluster Service, the ingress) with a roleless token
sees this, and a 401 is a truthful answer to it.
The JWKS is fetched on the first JWT and cached by the verifier (an hour,
FastMCP's default; a token with an unknown kid refetches, at most once a
minute, since FastMCP's bearer middleware runs on every path). A JWKS that
cannot be fetched refuses that request, and the server keeps running.
The MCP transport still needs the Bearer scheme (the MCP SDK's
BearerAuthBackend); the bare-token form stays an HTTP-routes convenience, as
today.
A new kubed/selenium_flow/principal.py, pure and FastMCP-free:
@dataclass(frozen=True)
class Principal:
kind: Literal["admin", "oidc"]
subject: str | None = None # the JWT's `sub`
username: str | None = None # `preferred_username`, else None
roles: tuple[str, ...] = ()
@property
def admin(self) -> bool: ... # kind == "admin"
def status(self) -> dict: ... # what session://current shows
@classmethod
def from_claims(cls, claims: Mapping, roles_claim: str) -> Principal: ...
def roles_in(claims: Mapping, roles_claim: str) -> tuple[str, ...]: ...roles_in is the one reading of the roles claim: the verifier's role check and
from_claims both call it, so they cannot disagree about what a token holds.
The server token is the admin principal: the operator, who sees everything.
A JWT is an oidc principal, whatever its roles; making a JWT role an admin is
next round's.
The verifiers produce the principal. Both return a PrincipalToken, an
AccessToken subclass carrying principal: Principal, so nothing downstream
re-reads claims or needs roles_claim. Caller gains
principal: Principal | None = None, and mcp/clients.caller() fills it from
get_access_token(). Nothing decides anything from it this round. None means
the server is open.
session://current gains one key, always present:
"principal": {"kind": "oidc", "subject": "6b0fβ¦", "username": "drk"}{"kind": "admin"} for the server token, null on an open server. Roles are
not shown: they are an authorisation input, not something an agent needs.
The REST routes, the admin API, the event stream and signed links keep calling
http/auth.authorized(request, token) with the server token. Sessions, flows,
files, site data and the secrets catalogue stay keyed by session name alone.
/ready and /info stay tokenless.
In the cluster repo (apps/selenium/components/mcp); files applied, no git
there (Dr K). Live since 2026-10-04 with the server token: the gateway
validates the Keycloak JWT and sends selenium-flow the server token
(backend.auth.secretRef), the pattern for any token-protected MCP server. This
round switches that to passthrough once the server verifies JWTs:
-
gateway.yaml, modelled onapps/mcp-kb/gateway.yaml, added to the component'sresources::-
AgentgatewayBackend selenium-flow: static MCP target, theselenium-flowService on port 8000,StreamableHTTP(path/mcp). -
HTTPRoute selenium-flow: parentnetwork/agentgateway-proxy, sectionmcp; hostmcp.<domain>;PathPrefix /selenium-flow/mcpand/.well-known/oauth-protected-resource/selenium-flow/mcp. -
AgentgatewayPolicy selenium-flowon that route:jwtAuthenticationStrict, the realm issuer, the shared MCP audience, the realm JWKS;mcp.resourceMetadataresource: https://mcp.<domain>/selenium-flow/mcp,scopesSupported: [mcp]; nomcp.provider(agentgateway#3668); the authorization rule on themcprole; andbackend.auth.passthrough: {}.
-
-
config.yamlgains theoidc:block: the realm issuer, the shared MCP audience, the realm JWKS URL,roles: [mcp]. The token stays in env. -
.mcp.json(/projects/cluster):selenium-flowmoves tohttps://mcp.<domain>/selenium-flow/mcp, dropsAuthorization(anAuthorizationheader stops Claude Code from doing OAuth), and keepsX-Session-Keyβ a header, because the gateway drops the query string. -
apps/agentgateway/AGENTS.md: the components table says what is live.
ROUTE_PREFIX stays unset: the gateway's MCP backend calls /mcp on the Service
directly, and the ingress keeps stripping /flow for the admin UI; its
mcp-block.yaml refuses /flow/mcp with a 403.
Through the real entry point (a Starlette TestClient on
server.mcp.http_app()), with a JWKS served for real: a fixture runs a stdlib
HTTP server on loopback publishing one RSA key, and tokens are signed with
FastMCP's RSAKeyPair.
- A valid JWT lists tools; an
initializewith it succeeds. - Refused with 401: wrong
aud, wrongiss, expired, signed by another key, holding none ofoidc.roles, arolesclaim that is not a list. - The server token still works on
/mcpwith OIDC on, and alone without it. -
session://currentnames the principal:oidcwith subject and username for a JWT,adminfor the token,nullwhen open. - A JWT is refused on a REST route (
/browser/...): the round's boundary, held.
Unit: Principal.from_claims / status; the roles-claim walk; the settings
(env names, comma split, all-or-nothing, URL check); load() refusing oidc
without a token, and accepting oidc in the file with AUTH_TOKEN in env;
ServerTokenVerifier accepting only the exact token.
The Grid-backed integration suite needs no change: it runs with the token. There is no Keycloak in CI; the live test below is where the real issuer is proven.
Live, on the cluster (the acceptance test):
- Deploy the branch image; apply the cluster changes; the gateway route and policy report Accepted/Attached.
-
GET /.well-known/oauth-protected-resource/selenium-flow/mcpnames Keycloak; an unauthenticatedPOST /selenium-flow/mcpis 401 withWWW-Authenticate. - In Claude Code,
/mcpβ authenticateselenium-flow(the localhost callback is pasted into the box, as for kb), then call a tool. -
session://currentshowskind: oidcand Dr K's username, and the session isclaudecode:X-Session-Keycrossed the gateway. - Check what only shows live: tools hidden per client (
clientInfoacross the gateway), a longassertand arun_flow(gateway timeouts), an MCP Apps view, a screenshot link. - In-cluster with the server token: unchanged. In-cluster with a JWT lacking
the
mcprole: 401.
- README: the auth section says the token, plus an OIDC issuer behind a gateway; one example block. It advertises, it does not explain.
-
AGENTS.md: the auth rules β the provider is built in
http/auth.py; the principal exists and decides nothing yet; the REST routes are token-only on purpose; OIDC needs the token. -
CHANGELOG (Unreleased): one line for OIDC, one for the constant-time token
on
/mcp, one forprincipalinsession://current. -
wiki
Configuration.mdandDeployment.md: theoidcsettings, and the gateway deployment.
- The admin UI's "sign in with OIDC" option, under the token field
(
oidc.client_id, PKCE, the same verifier). - Workspaces: the rename, and whether they get owners.
- A JWT on the REST routes.
- Per-tool roles (
execute_script,delete_flow, secret binding), which also filtertools/list. - The principal in logs and in the admin live list.
- A per-server audience, if the shared one ever lets a token for one server open another that should be stricter.
-
oidc, notauth.oidc: the schema is two levels deep. -
The token-needed rule runs after the merge, in
load(), so a file withoidcand an envAUTH_TOKENloads;http/auth.pyrefuses the same state forSettingsbuilt in code. -
No version bump of FastMCP:
MultiAuth,JWTVerifier(http_client=...),RSAKeyPair,AccessToken.claimsandget_access_tokenare all in 4.0.0.