Skip to content

feat(mecak8s): host AS-1 for identity broker sign-in - #2613

Merged
ChrisJBurns merged 4 commits into
mainfrom
feat/identitybroker-host-as1
Oct 30, 2026
Merged

ChrisJBurns merged 4 commits into
mainfrom
feat/identitybroker-host-as1

Conversation

@ChrisJBurns

@ChrisJBurns ChrisJBurns commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Hosts AS-1, Mecatl's own sign-in authorization server, in mecak8s. Closes #2490.

  • AS-1 adapter (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_access with prompt=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.
  • mecak8s: AS-1 is on when --identity-broker-as1-issuer is 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 silent sub breaks the AS-1/AS-2 identity match on Entra ID). Startup derives --oidc-issuer, --oidc-client-id, and --oidc-accepted-client-ids from AS-1 and refuses mismatches; requires --enable-identity-broker, --oidc-audience, --oidc-resource, and --oidc-owner-identity=sub_id; refuses --oidc-allow-private-https-issuer and any --oidc-jwks-uri. AS-1 routes are mounted with a conflict check, rate-limited with two global buckets, one for /oauth/token and 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.
  • Broker unchanged: the identity broker is not pointed at AS-1 here (identityBrokerAS1 stays 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.
  • Lint boundary: a new strict depguard rule allows as1server the five ToolHive packages it uses; the other broker adapters keep their rule. Added to TestToolHiveImportsStayBehindApprovedAdapterLeaves.
  • Helm: new identityBroker section (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; forces replicaCount: 1 and a Recreate rollout when the broker is on, and refuses --enable-identity-broker in extraArgs so the single replica cannot be bypassed.
  • Docs: "Host AS-1 in mecak8s" in the mecak8s identity guide (issuer stability, IdP registration, subject claim, restarts, consent page, Studio not yet supported, existing sessions keep their owners) and a security-boundaries row for executor isolation; regenerated server CLI reference.

Decisions

Recorded in #2490's body:

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-execution default-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-resource is required with AS-1 (mecatui needs the metadata to find AS-1), and the chart forces a single replica and Recreate with the broker on.

Testing

  • as1server tests against the real ToolHive AS-1 with the offline harness IdP: token claims (typ at+jwt, aud, client_id, jti, sub_id), no sub_id for another audience, subject claim reaching upstream_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/mecak8s end to end over TLS: protected-resource metadata names AS-1 and the TUI client; the real clientauth.Login signs in through AS-1 (consent page included); the IdP receives offline_access + prompt=consent; sessions open with sub_id ownership; 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.
  • Helm: chart_test.go, helm-unittest, kubeconform fixture.
  • Offline demo shows tool call, permission ask and approval, and result.

Local notes: task lint stops at an existing gosec G703 in environment/microvm/launch_ownership_darwin.go:754. A few tests in cmd/mecatui, cmd/mecated, internal/app, internal/adapter/server, and deploy/helm/mecatl-execution fail locally on clean main too (worktree path and Helm version); CI is the reference. task docs:server-cli:check reports a path-dependent wrap of mecated's --workspace default in a deep checkout; the committed line matches main.

API compatibility

  • Assessment: additive and opt-in. With --identity-broker-as1-issuer unset, 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] and client_id name 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.
  • Clients: mecatui requires exactly one authorization server, which holds. Studio is not supported against AS-1 deployments yet (documented).
  • Major / features: no major bump; no new feature identifier.
  • Discovery: AS-1's discovery documents list only the endpoints it serves; dynamic client registration and device flow are omitted.
  • Upgrade: existing deployments are unaffected. Turning AS-1 on changes --oidc-issuer to 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 with mecatui login. Charts that passed --enable-identity-broker in extraArgs must switch to identityBroker.enabled.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/mecak8s/main.go Outdated
defer boundedClose(built.Close, cfg.closeTimeout)
defer flushTelemetry(os.Stderr, obs, cfg.otlpShutdownTimeout)

cfg.as1Server = built.IdentityBrokerHostedAS1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased onto main after #2620 landed; the change is now 65a2a75, and serveWithHooks (added in #2620) takes builtServing too.

ChrisJBurns and others added 4 commits October 30, 2026 13:37
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>
@ChrisJBurns
ChrisJBurns force-pushed the feat/identitybroker-host-as1 branch from 99fe64e to 65a2a75 Compare October 30, 2026 13:39
@ChrisJBurns
ChrisJBurns merged commit c893660 into main Oct 30, 2026
45 checks passed
@ChrisJBurns
ChrisJBurns deleted the feat/identitybroker-host-as1 branch October 30, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host AS-1 in mecak8s

2 participants