Repository navigation
feat(mecak8s): host AS-1 for identity broker sign-in - #2613
Conversation
3a1efa8 to
a24b2b5
Compare
jhrozek
left a comment
There was a problem hiding this comment.
Approving. I read all three commits (hosting AS-1, the generic public clients, and the hardening follow-ups), and CI is green. One optional suggestion inline, nothing blocking.
| defer boundedClose(built.Close, cfg.closeTimeout) | ||
| defer flushTelemetry(os.Stderr, obs, cfg.otlpShutdownTimeout) | ||
|
|
||
| cfg.as1Server = built.IdentityBrokerHostedAS1 |
There was a problem hiding this comment.
Optional: could the hosted AS-1 travel to serve as a parameter, the way built.MCPBrokerHandlers does, instead of through config?
config is parsed flag input, and this line puts a build result into it. It has to be set after app.Build and before serve(ctx, cfg, ...), which nothing in the signatures says. newAuthenticator also needs a guard (cfg.as1Server == nil || cfg.as1Server.Issuer != oidc.Issuer in serve.go) because the flags and the stashed object come from two different sources. The tests set the field by hand too (as1_sign_in_harness_test.go, identity_broker_flag_test.go).
With a parameter, newAuthenticator could do if as1 != nil { oidc.HTTPClient = as1.HTTPClient } and config stays pure input.
I know serveWithDrainWait already takes seven parameters. If an eighth bothers you, a small struct for what comes out of Build would do. Not blocking.
There was a problem hiding this comment.
Agreed, done in 99fe64e. serve, serveWithDrainWait, and normalHTTPMux now take a small builtServing struct with what they need from app.Build (the MCP broker handlers, their callback path, and the hosted AS-1), so the parameter count went down rather than up. newAuthenticator takes the hosted AS-1 directly and only does if as1 != nil { oidc.HTTPClient = as1.HTTPClient }; config.as1Server and the issuer guard are gone. If AS-1 were configured but not passed, the validator would fetch discovery over the network before the listeners start, so startup still fails closed. TestHostedAS1MustBeBuiltBeforeServing is replaced by a test at the newAuthenticator boundary that the validator receives the hosted AS-1's in-process client when one is passed, and none otherwise.
With --identity-broker-as1-issuer set (and --enable-identity-broker), mecak8s hosts AS-1, a ToolHive embedded authorization server built in process, in front of the company IdP. The TUI signs in there and gets an RFC 9068 Mecatl API token (typ at+jwt, sub_id) that passes the sub_id ownership checks. mecak8s derives its OIDC issuer and advertised client from AS-1 and validates AS-1 tokens in process before listening. AS-1's anonymous routes are rate-limited and size-capped. The identity broker is not pointed at AS-1 yet (#2607), so session opens are unchanged. AS-1's adapter lives in the broker tree (internal/identitybroker/adapter/ as1server) with its own depguard rule; it uses its own in-memory storage and an optional signing-key file. The Helm chart gains an identityBroker section that mirrors the startup rules. Closes #2490 Co-authored-by: Mecatl <noreply@mecatl.dev>
The identity broker may later run as its own service, so its AS-1 adapter should take client registrations as configuration rather than know about Mecatl's TUI. as1server.Config and app.IdentityBrokerAS1Config now take a list of public clients (ID and redirect URIs); mecak8s builds a one-entry list for the TUI from its existing flags. Validation requires at least one client with a unique ID and an absolute redirect URI. Refs #2490 Co-authored-by: Mecatl <noreply@mecatl.dev>
Review follow-ups: - Rate-limit AS-1 with global buckets, one for /oauth/token and one for the other routes, instead of per peer: behind an ingress every user shares one peer address. - Cap pending authorizations in AS-1's memory store, so unauthenticated authorize requests cannot grow it until the pod restarts. - Drop endpoints AS-1 does not serve (dynamic client registration, device flow) from its discovery documents. - Refuse --enable-identity-broker in the chart's extraArgs; use identityBroker.enabled, which enforces one replica. - Require --identity-broker-subject-claim with AS-1: a silent "sub" default breaks the AS-1/AS-2 identity match on Entra ID. - Document ingress rate limiting, the pending cap, and that enabling AS-1 stops company-IdP tokens and Studio from working. Refs #2490 Co-authored-by: Mecatl <noreply@mecatl.dev>
…onfig config is parsed flag input; the hosted AS-1 is a build result. serve, serveWithDrainWait, and normalHTTPMux now take a small builtServing struct with what they need from app.Build (the MCP broker handlers, its callback path, and the hosted AS-1), and newAuthenticator takes the hosted AS-1 directly. This removes the hidden ordering between app.Build and serve and the issuer guard that existed only because the flags and the stored object came from two places. Refs #2490 Co-authored-by: Mecatl <noreply@mecatl.dev>
99fe64e to
65a2a75
Compare
Summary
Hosts AS-1, Mecatl's own sign-in authorization server, in mecak8s. Closes #2490.
internal/identitybroker/adapter/as1server): builds the real ToolHive v0.58.0 embedded authorization server in process, with its own in-memory storage, an optional signing-key file (generated otherwise), the company IdP as its OIDC upstream (offline_accesswithprompt=consent, configurable subject claim), RFC 9068 tokens for the Mecatl API (WithAccessTokenType("at+jwt"),WithSubjectIdentifierAudiences), and a configured list of public PKCE clients. The adapter knows nothing about the TUI: mecak8s, the composition root, passes a one-entry list for it, so the broker can later run as its own service and receive client registrations as configuration. It serves only the sign-in routes (no dynamic client registration or device flow), drops the endpoints it does not serve from its discovery documents, caps pending authorizations in its memory store (10,000 new, plus a 10% reserve for sign-ins already past consent), and gives mecak8s an in-process HTTP client confined to the issuer.--identity-broker-as1-issueris set. New flags:--identity-broker-as1-{issuer,signing-key-file,tui-client-id,upstream-issuer,upstream-client-id,upstream-client-secret-file}and--identity-broker-subject-claim(named so the embedded AS-2 can reuse it, Configure the embedded gateway's authorization server as AS-2 #2533; required with AS-1, no default, because a silentsubbreaks the AS-1/AS-2 identity match on Entra ID). Startup derives--oidc-issuer,--oidc-client-id, and--oidc-accepted-client-idsfrom AS-1 and refuses mismatches; requires--enable-identity-broker,--oidc-audience,--oidc-resource, and--oidc-owner-identity=sub_id; refuses--oidc-allow-private-https-issuerand any--oidc-jwks-uri. AS-1 routes are mounted with a conflict check, rate-limited with two global buckets, one for/oauth/tokenand one for the other routes (not per peer: behind an ingress every user shares one peer address), with an 8 KiB query and 64 KiB body cap. The docs recommend per-client rate limiting at the ingress. The OIDC validator fetches AS-1's discovery and keys in process, before listeners start.identityBrokerAS1stays unset), because AS-1 would refuse the unregistered broker client. That moves to Register the broker client at AS-1 and point the broker at it #2607, which waits on unreleased ToolHive work.as1serverthe five ToolHive packages it uses; the other broker adapters keep their rule. Added toTestToolHiveImportsStayBehindApprovedAdapterLeaves.identityBrokersection (enabled,subjectClaim,as1.*, Secret refs for the signing key and upstream client secret) with render-time refusals mirroring startup, including no shell in the process; forcesreplicaCount: 1and aRecreaterollout when the broker is on, and refuses--enable-identity-brokerinextraArgsso the single replica cannot be bypassed.Decisions
Recorded in #2490's body:
sub_idownership leaves existing sessions with their old owners.sub_idand is unaffected). It is listed in Phase 1: Signed-in users' agents call gateway tools with their own access #2451's shortcut table, and Durable broker state and recovery #2563 (durable broker state) makes it persistent.For review — executor isolation. #2490 asked for a render test of "mecak8s's listener rules". The broker and AS-1 run inside the mecak8s process with in-memory state, so there is no separate broker listener or store; AS-1's sign-in endpoints are public by design. This PR documents relying on the
mecatl-executiondefault-deny policy rather than adding a new mecak8s ingress NetworkPolicy. Push back if you want the policy.Separate broker service later. Everything AS-1 needs stays movable: its issuer, storage, keys, and ToolHive code live in the broker's tree, and mecak8s's in-process validation is a generic HTTP-client seam it simply wouldn't set. AS-1's configuration currently arrives through mecak8s's flags; a separate broker would load its own configuration (#2538 owns broker configuration loading).
Additions beyond the ticket:
--oidc-resourceis required with AS-1 (mecatui needs the metadata to find AS-1), and the chart forces a single replica andRecreatewith the broker on.Testing
as1servertests against the real ToolHive AS-1 with the offline harness IdP: token claims (typ at+jwt,aud,client_id,jti,sub_id), nosub_idfor another audience, subject claim reachingupstream_subject, repeated sign-in keeps the subject, refresh rotation and resource checks, key-file restart, served routes only, root issuer, in-process client confinement, config validation.cmd/mecak8send to end over TLS: protected-resource metadata names AS-1 and the TUI client; the realclientauth.Loginsigns in through AS-1 (consent page included); the IdP receivesoffline_access+prompt=consent; sessions open withsub_idownership; refresh works; a pre-restart token still passes with a key file; no token exchange reaches AS-1. Plus flag-rule matrix, route conflicts (incl. beside the MCP broker), rate and size limits, flag off.chart_test.go, helm-unittest, kubeconform fixture.Local notes:
task lintstops at an existing gosec G703 inenvironment/microvm/launch_ownership_darwin.go:754. A few tests incmd/mecatui,cmd/mecated,internal/app,internal/adapter/server, anddeploy/helm/mecatl-executionfail locally on cleanmaintoo (worktree path and Helm version); CI is the reference.task docs:server-cli:checkreports a path-dependent wrap of mecated's--workspacedefault in a deep checkout; the committed line matchesmain.API compatibility
--identity-broker-as1-issuerunset, nothing changes on the wire. With it set, mecak8s serves new anonymous OAuth and discovery routes outside/v1, and the protected-resource metadata keeps exactly one authorization server;authorization_servers[0]andclient_idname AS-1 and its TUI client instead of the company IdP. feat(mecatui): sign in at AS-1 from the TUI #2572 already published the AS-1 sign-in signal and assessed it.--oidc-issuerto AS-1, so mecak8s then accepts only AS-1 tokens: tokens issued directly by the company IdP stop working, and Studio stops working until it supports AS-1. Operators opting in register AS-1 at their IdP and users sign in again withmecatui login. Charts that passed--enable-identity-brokerinextraArgsmust switch toidentityBroker.enabled.