Skip to content

feat(mecatui): sign in at AS-1 from the TUI - #2572

Merged
ChrisJBurns merged 6 commits into
mainfrom
feat/clients-sign-in-as1
Oct 27, 2026
Merged

ChrisJBurns merged 6 commits into
mainfrom
feat/clients-sign-in-as1

Conversation

@ChrisJBurns

@ChrisJBurns ChrisJBurns commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

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 resource parameter.

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 mecak8s that signs users in through the identity broker.

  • Server signal. With --oidc-owner-identity=sub_id (the AS-1 token profile from Session ownership from the AS-1 sign-in #2487), the protected-resource metadata adds com.stacklok.mecatl.identity_broker_sign_in: true. There is no new flag.
  • TUI.
    • Saves the signal with the connection.
    • When it is set:
      • sends resource = audience on the authorize request, the code exchange, and refresh;
      • forces a refresh before create, fork (including the model-switch fork), and clear;
      • forces a refresh before the first run-entry call (prompt, approval, compact, MCP recheck or cancel) for a session this client has not yet run successfully, which covers every resume path.
    • Without the signal, refresh uses x/oauth2's standard path, exactly as before.
    • On any deployment with a refresh token: on an authenticator rejection (gRPC Unauthenticated carrying ErrorInfo reason bearer_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.
  • Offline AS-1 harness. Checks resource when a public client sends it (it must equal the audience), rotates refresh tokens on every refresh, and exports Harness.Authorize so a real client can complete the browser step.
  • Docs. caller-identity.md (the new metadata member) and a limitation note in operating/studio.md.

Issue relationship

Closes #2491. Part of #2454.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • task lint: clean for every module this PR touches. It stops on a pre-existing gosec finding in environment/microvm, which this PR does not touch.
  • task test:race and the root module with -race: every failing test also fails on a clean origin/main checkout 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.
  • After rebasing on main and removing Studio: go test -race on internal/adapter/clientauth, cmd/mecatui/client, cmd/mecak8s, internal/cliconfig, and internal/testutil/identityharness passes. golangci-lint on the touched packages is clean.
  • task docs:check (0 broken links) and task site:build. task docs did not run: buf is not installed locally.
  • Not run: the offline demo.

Key tests:

  • cmd/mecak8s/tui_sign_in_harness_test.go: real mecak8s with the AS-1 profile and the offline AS-1.
    • The metadata publishes the signal.
    • The real TUI sign-in code completes.
    • resource is sent on every leg.
    • Two creates carry tokens with different jti.
  • cmd/mecatui/client/fresh_token_test.go:
    • With the signal: create, fork, carryover fork, and clear each refresh once and never share a token. A resumed session's first run entry refreshes once, and a failed first run entry leaves the next one fresh.
    • Without the signal: nothing refreshes.
    • One rejection gives one retry; two give auth-rejected after exactly one retry.
    • Converse and MCP authorization streams reopen once, and only before any response.
    • Static tokens are untouched.
  • internal/adapter/clientauth:
    • resource is sent only with the signal.
    • Without the signal, refresh takes the standard path, so a form-encoded token response is accepted.
    • Rotation is persisted, and a retired refresh token gives session-expired.
    • An old saved record without the field refreshes without resource.
  • Mutation checks: the refresh gating, the refresh path choice, retry once, the stream retry window, and the opened-session marking were each broken in turn, and the tests failed.

API compatibility

Assessment: compatible and additive; no major bump.

  • One private member is added to the RFC 9728 protected-resource document: com.stacklok.mecatl.identity_broker_sign_in. It is published as true only with --oidc-owner-identity=sub_id and omitted otherwise.
  • The authenticator's gRPC bearer rejections now also carry google.rpc.ErrorInfo{reason: "bearer_rejected", domain: "mecatl.stacklok.com"}. The status code and message are unchanged, and these rejections carried no ErrorInfo before, so no existing client depends on its absence. Handler-level unauthenticated errors and HTTP responses are unchanged. There is no proto change.
  • A new TUI against an older server (no tag) shows the rejection without retrying. That fails safe, and AS-1 sign-in has not shipped.
  • Old clients ignore the member. Against a real AS-1 they could not get the right audience anyway.
  • New clients against old servers, or against servers without the AS-1 profile, see no member and behave as today, apart from the single retry on a rejected token.
  • Stored data: the TUI's saved connection gains identity_broker_sign_in (omitted when false), so existing records are unchanged. An older mecatui rejects a record that has the field; running mecatui login again 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

  • AS-1's token_used and subject_invalid 401s arrive with Convert the sign-in at session open #2492. Until then the retry is exercised with plain rejections.
  • The TUI retries only the authenticator's tagged rejection, so an Unauthenticated raised after a handler started work is never repeated. Convert the sign-in at session open #2492 must tag its token_used and subject_invalid 401s and return them before any change (tracked there).
  • Explicit mecatui login --issuer ... never sends resource.

ChrisJBurns and others added 2 commits October 26, 2026 23:31
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>
@ChrisJBurns
ChrisJBurns force-pushed the feat/clients-sign-in-as1 branch from be0b310 to 28bafe6 Compare October 26, 2026 23:32
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>
@ChrisJBurns ChrisJBurns changed the title feat(clients): sign in at AS-1 from the TUI and Studio feat(mecatui): sign in at AS-1 from the TUI Oct 27, 2026
- 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 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.

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.

Comment thread cmd/mecatui/client/client.go Outdated

// 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,

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.

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 ErrorInfo reason like bearer_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_used can still be retried as the design wants, because a 401 is always before any work by construction.

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.

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}

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.

nit (non-blocking): two small edge cases in refreshGrant:

  1. An empty token_type loses the rotated refresh token. token_type is copied as is. In exchangeAndPersist, the early save of a rotated refresh token requires validToken(replacement), which needs a non-empty TokenType, and the late save is skipped because rotated is true. So if AS-1 rotates but omits token_type, the call succeeds but the new refresh token is never saved, and the next forced refresh sends the retired one, gets invalid_grant, and signs the user out. RFC 6749 requires token_type, so a compliant AS-1 won't hit this, but this path now runs before every session open. Defaulting an empty token_type to Bearer here (plus a harness case that omits it) would close it.
  2. 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 and Response is nil, and x/oauth2 v0.37's RetrieveError.Error() then dereferences r.Response.Status. Nothing formats it today (exchangeAndPersist only checks isInvalidGrant), but the first %v or log line would crash the TUI. Returning a plain error when there's no OAuth error code avoids it.

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.

Both are fixed in d351186.

  1. exchangeAndPersist and Login now use tok.Type(), so an empty token_type becomes Bearer. 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: omitTokenType cases with and without the broker sign-in signal, plus TestRefreshUnsavableRotationIsAnError.
  2. A non-2xx response becomes a RetrieveError only when the body decodes with a non-empty error. Otherwise it is token endpoint returned HTTP %d, without the body. Test: a 502 with an HTML body, which also checks that calling Error() 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 {

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.

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.

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.

The end-to-end test already checks resource from the client side on every leg:

  • Authorize: the presenter fails Login unless the authorization URL's resource equals 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 both Login and NewRefreshSource use. It records each token POST's resource by 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>
@ChrisJBurns
ChrisJBurns merged commit a663514 into main Oct 27, 2026
43 of 44 checks passed
@ChrisJBurns
ChrisJBurns deleted the feat/clients-sign-in-as1 branch October 27, 2026 16:01
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.

TUI signs in at AS-1

2 participants