Repository navigation
feat(mecatui): sign in at AS-1 from the TUI - #2572
Conversation
With --oidc-owner-identity=sub_id, mecak8s publishes com.stacklok.mecatl.identity_broker_sign_in in its protected-resource metadata. Clients that see it send the RFC 8707 resource parameter and refresh their access token before each request that opens a session, because AS-1 accepts each access token for one session open. Without it they behave as before, so identity providers that reject resource keep working. Both clients refresh and retry once on a rejected bearer. Studio gains its own client ID setting (STUDIO_OIDC_CLIENT_ID, Helm oidc.clientID). The offline AS-1 checks resource and rotates refresh tokens. Closes #2491 Co-authored-by: Mecatl <noreply@mecatl.dev>
Studio: - Only streams whose route pulls the first item before committing (run, retry, MCP authorization) get the refresh and retry; others keep their previous behaviour, so a refresh never lands after the response. - Remember retired refresh tokens for 24h (per replica, bounded) so a browser left with an older rotated cookie is mapped to the newest. - guardChat keeps getters live; guard() includes prototype methods. - A session counts as opened only after its first run entry succeeds. TUI: - A session counts as opened only after its first run entry succeeds. - MCP authorization recheck and cancel streams reopen once on a rejection before any response, as Converse does. Refs #2491 Co-authored-by: Mecatl <noreply@mecatl.dev>
be0b310 to
28bafe6
Compare
Studio's AS-1 support moves to a phase 3 issue, so this reverts its code, chart, and docs changes and notes that Studio does not yet support a mecak8s that signs users in through the identity broker. The TUI keeps x/oauth2's standard refresh unless the server publishes identity_broker_sign_in, and the Converse and MCP authorization streams share one reopen-once helper. Refs #2491 Co-authored-by: Mecatl <noreply@mecatl.dev>
- Split the reopen stream's lock: Send calls are serialised by their own lock, and receive never waits behind a Send blocked on flow control. - A frame whose Send failed is not replayed on reopen. - A refresh that fails because the issuer is unreachable is reported as that failure, not as a rejected credential. - Docs: users enrolled before the server published identity_broker_sign_in run mecatui login again. Refs #2491 Co-authored-by: Mecatl <noreply@mecatl.dev>
jhrozek
left a comment
There was a problem hiding this comment.
Looks good. Gating resource and the forced refresh on the server's metadata flag keeps existing deployments unchanged. The rotated refresh token is saved under the target lock before validation, so concurrent forced refreshes never reuse a retired one. The stream reopen locking holds up.
Three non-blocking nits inline. The first one is the important one: it's about the next PR (session open, #2492) rather than this one.
|
|
||
| // tokenSourceUnary attaches the source's token and, when the server rejects it | ||
| // (Unauthenticated) and the source can refresh, retries exactly once with a | ||
| // freshly refreshed token. The authenticator rejects before any handler runs, |
There was a problem hiding this comment.
nit (non-blocking, but worth settling before #2492): the retry here and the frame replay in reopen.go are safe only while every Unauthenticated comes from the authenticator, before a handler has changed anything. That's true today: create, fork, and clear discard the broker-open error (openSavedIdentityBrokerSession), and run entry opens the broker before the run starts. But it's a rule only this client comment knows about. If #2492 makes create, fork, or clear return token_used as 401 after the host session is saved, this retry creates a second session; if a 401 ever comes after a run has started, the Converse replay runs the prompt twice.
Rather than documenting the rule, I'd rather make it impossible to break. Two options:
- Retry only on 401s from the door. The server's authenticator tags its rejection (for example a gRPC
ErrorInforeason likebearer_rejected), and the client retries or replays only when it sees that tag. A 401 from inside a handler is then surfaced, never retried. Small enough to fit in this PR. - Check the token before saving anything (Convert the sign-in at session open #2492). Create, fork, and clear open the broker session, which spends the AS-1 token, before persisting the host session, and close it again if the save fails. Then
token_usedcan still be retried as the design wants, because a 401 is always before any work by construction.
There was a problem hiding this comment.
Done in d351186, with option (a). The authenticator's gRPC bearer rejections now carry ErrorInfo{reason: "bearer_rejected", domain: "mecatl.stacklok.com"}. The TUI retries, or replays a stream, only on that tag. An untagged Unauthenticated, including the handler-level unauthenticated from execution templates and connector status, is shown and never repeated. Negative tests cover both the unary and the stream paths.
Option (b) is tracked in #2492 ("A 401 must come before any change"). I've also noted there that its token_used and subject_invalid 401s must carry the tag, so the TUI still retries them.
| if decodeErr != nil || payload.AccessToken == "" { | ||
| return nil, errors.New("token response malformed") | ||
| } | ||
| tok := &oauth2.Token{AccessToken: payload.AccessToken, TokenType: payload.TokenType, RefreshToken: payload.RefreshToken} |
There was a problem hiding this comment.
nit (non-blocking): two small edge cases in refreshGrant:
- An empty
token_typeloses the rotated refresh token.token_typeis copied as is. InexchangeAndPersist, the early save of a rotated refresh token requiresvalidToken(replacement), which needs a non-emptyTokenType, and the late save is skipped becauserotatedis true. So if AS-1 rotates but omitstoken_type, the call succeeds but the new refresh token is never saved, and the next forced refresh sends the retired one, getsinvalid_grant, and signs the user out. RFC 6749 requirestoken_type, so a compliant AS-1 won't hit this, but this path now runs before every session open. Defaulting an emptytoken_typetoBearerhere (plus a harness case that omits it) would close it. - Line 490:
&oauth2.RetrieveError{ErrorCode: payload.Error}with an empty code panics if it's ever printed. For a non-OAuth error body (e.g. a proxy's 502 page) the code is empty andResponseis nil, and x/oauth2 v0.37'sRetrieveError.Error()then dereferencesr.Response.Status. Nothing formats it today (exchangeAndPersistonly checksisInvalidGrant), but the first%vor log line would crash the TUI. Returning a plain error when there's no OAuth error code avoids it.
There was a problem hiding this comment.
Both are fixed in d351186.
exchangeAndPersistandLoginnow usetok.Type(), so an emptytoken_typebecomesBearer. This also covers the standard x/oauth2 refresh path, which copied the field as is too. A rotated refresh token is now always saved straight away, and if the store refuses it, the refresh fails instead of silently skipping the save. Tests:omitTokenTypecases with and without the broker sign-in signal, plusTestRefreshUnsavableRotationIsAnError.- A non-2xx response becomes a
RetrieveErroronly when the body decodes with a non-emptyerror. Otherwise it istoken endpoint returned HTTP %d, without the body. Test: a 502 with an HTML body, which also checks that callingError()doesn't panic.
|
|
||
| // validResource reports whether an optional RFC 8707 resource names the Mecatl | ||
| // API, the only resource AS-1 issues tokens for (so aud stays the audience). | ||
| func (a *AS1) validResource(values url.Values) bool { |
There was a problem hiding this comment.
nit (non-blocking): the harness accepts a request with no resource at all, so cmd/mecak8s/tui_sign_in_harness_test.go would still pass if the TUI stopped sending it; only the clientauth unit tests prove it's sent. An option to make the harness require resource (or recording what it received, as AS-1 now does for client assertions), turned on in the end-to-end test, would let that test prove it on every leg.
There was a problem hiding this comment.
The end-to-end test already checks resource from the client side on every leg:
- Authorize: the presenter fails
Loginunless the authorization URL'sresourceequals the audience (cmd/mecak8s/tui_sign_in_harness_test.go:193). - Code exchange and refresh:
resourceRecorder(:87-130) wraps the AS client's transport, which bothLoginandNewRefreshSourceuse. It records each token POST'sresourceby grant type, and the test requires one code exchange, at least two refreshes, and no request missing it (:248).
To confirm, I removed resource from each leg in turn in a scratch copy. The test failed every time: at :204 for the authorize request, and at :249 with missing:1 for the code exchange and missing:2 for the refreshes.
You're right that the harness itself accepts a request without resource. A RequireResource option would move that check to the server side; happy to add it if you'd prefer it there as well.
The authenticator's gRPC bearer rejections now carry ErrorInfo reason bearer_rejected. The TUI refreshes and retries, or replays a stream, only on that tag, so an Unauthenticated raised after a handler started work is shown rather than repeated. Refresh: an empty token_type defaults to Bearer so a rotated refresh token is always saved (a rotation that cannot be saved is now an error), and a non-OAuth error response is a plain error rather than a RetrieveError that panics when printed. Refs #2491 Co-authored-by: Mecatl <noreply@mecatl.dev>
Summary
The TUI signs in at AS-1 and uses its tokens the way AS-1 needs. AS-1 accepts each access token for only one session open, so on an AS-1 deployment the TUI now refreshes its token before every request that opens a session. It also asks AS-1 for the Mecatl API audience with the RFC 8707
resourceparameter.Both behaviours apply only when the server says it uses AS-1, so existing deployments are unchanged. This matters because some identity providers, such as Microsoft Entra ID, have rejected
resource(AADSTS901002).Studio support is deferred to phase 3. Studio's docs now say it does not yet support a
mecak8sthat signs users in through the identity broker.--oidc-owner-identity=sub_id(the AS-1 token profile from Session ownership from the AS-1 sign-in #2487), the protected-resource metadata addscom.stacklok.mecatl.identity_broker_sign_in: true. There is no new flag.resource= audience on the authorize request, the code exchange, and refresh;ErrorInforeasonbearer_rejected), the TUI refreshes and retries once; an untagged Unauthenticated is shown, never retried. Converse and MCP authorization streams reopen once if they are rejected before any response; both share one helper (cmd/mecatui/client/reopen.go). A second failure shows the existing auth-rejected screen.resourcewhen a public client sends it (it must equal the audience), rotates refresh tokens on every refresh, and exportsHarness.Authorizeso a real client can complete the browser step.caller-identity.md(the new metadata member) and a limitation note inoperating/studio.md.Issue relationship
Closes #2491. Part of #2454.
Type of change
Test plan
task lint: clean for every module this PR touches. It stops on a pre-existing gosec finding inenvironment/microvm, which this PR does not touch.task test:raceand the root module with-race: every failing test also fails on a cleanorigin/maincheckout in the same sandbox (git worktrees, shell, sockets, and a TLS test key failing with "certificate is using a broken key size"). None fails only on this branch.go test -raceoninternal/adapter/clientauth,cmd/mecatui/client,cmd/mecak8s,internal/cliconfig, andinternal/testutil/identityharnesspasses.golangci-linton the touched packages is clean.task docs:check(0 broken links) andtask site:build.task docsdid not run:bufis not installed locally.Key tests:
cmd/mecak8s/tui_sign_in_harness_test.go: real mecak8s with the AS-1 profile and the offline AS-1.resourceis sent on every leg.jti.cmd/mecatui/client/fresh_token_test.go:internal/adapter/clientauth:resourceis sent only with the signal.resource.API compatibility
Assessment: compatible and additive; no major bump.
com.stacklok.mecatl.identity_broker_sign_in. It is published astrueonly with--oidc-owner-identity=sub_idand omitted otherwise.google.rpc.ErrorInfo{reason: "bearer_rejected", domain: "mecatl.stacklok.com"}. The status code and message are unchanged, and these rejections carried noErrorInfobefore, so no existing client depends on its absence. Handler-levelunauthenticatederrors and HTTP responses are unchanged. There is no proto change.identity_broker_sign_in(omitted when false), so existing records are unchanged. An oldermecatuirejects a record that has the field; runningmecatui loginagain fixes it.Upgrade implications: none for existing deployments. Registering the TUI at the real AS-1 is #2490. Studio should not be pointed at an AS-1 deployment until its phase 3 work lands.
Special notes for reviewers
token_usedandsubject_invalid401s arrive with Convert the sign-in at session open #2492. Until then the retry is exercised with plain rejections.token_usedandsubject_invalid401s and return them before any change (tracked there).mecatui login --issuer ...never sendsresource.