Skip to content

[AI-1543] Check the account on a session connection's first consent - #1110

Merged
kanat merged 7 commits into
acceleratefrom
connectors/first-consent-account
Oct 21, 2026
Merged

kanat merged 7 commits into
acceleratefrom
connectors/first-consent-account

Conversation

@kanat

@kanat kanat commented Oct 21, 2026

Copy link
Copy Markdown

AI-1543: check the account on a session connection's first consent

A consent link works in any browser (authorizations.go, launch page + cookie). Staff member A can send it to B, and B's Nessy grant would land on A's connection, so A's agent would act as B. The account check ran only once a connection already had an account.

Decision (Q6 amended, 2026-10-21): the app's backend says the user's email, with the same trust as X-Stream-User-Id. The router keeps the latest one per user and compares it with the id_token email on the first consent.

Athena backend ──server token + X-Stream-User-Id: anya + X-Stream-User-Email: anya@getstream.io──▶ router
                                                                    withCustomer → users.email = anya@getstream.io
                                                                    (a device's token: header ignored)

browser ──▶ Nessy (whoever signs in) ──code──▶ /v1/agents/connectors/oauth/callback
   completeConsent:
     scheme.Complete → token response → id_token: iss/aud/exp checked (OIDC Core §3.1.3.7 item 6)
                     → capture sub, email, email_verified
     manifest.owner_email && owner_type=user && no account yet:
        users.email == id_token.email (trim, case-insensitive), email_verified ≠ false
          yes → store grant, status=connected
          no / no email said → store nothing, status=account_mismatch, log the reason
Piece Where
X-Stream-User-Email, read from a server-side caller only (APIKey server branch; Proxy KindServer) internal/auth/auth.go
Keep the latest per user (users.email, migration 20261105140000) internal/api/server.go recordEmail, internal/store/sessions.go SetUserEmail/UserEmail
owner_email: true manifest opt-in; needs a required email capture from the id_token internal/connectors/core/manifest.go
The check, before the grant is written, under the credential lock internal/api/authorizations.go completeConsent, ownerEmailRefusal
Nessy opts in: captures sub (identity), email, optional email_verified; revision 2 providers/nessy.yaml
The connection proxy strips the new header like the other caller headers internal/api/connection_proxy.go
Fake provider OpenID personality (Nessy's id_token shape) fakeprovider

First production caller: the consent callback /v1/agents/connectors/oauth/callback (finishConnectorConsent → completeConsent). Consents begun from a session's tool call (ConnectorConsents) finish at the same callback and are checked against the same stored email (TestAChatConsentIsCheckedAgainstTheSameEmail).

No other connector changes: only a manifest with owner_email is checked (TestAConnectorThatDoesNotCheckTheEmailConnectsAnyAccount, whose expected connected was probed on base a103c19ab). App-owned connections and reconnects of a connection that already has an account are not email-checked (a reconnect keeps the existing account check).

Docs: audit.go actor_name no longer says the router keeps no email; README auth section, providers AGENTS.md, security-scheme description, sdk skill note for the other SDKs. Clients regenerated: Go, Python, JS (Kotlin/Swift --check pass, nothing client-accessible changed).

What Athena must change (not in this PR): send X-Stream-User-Email: <employee email> beside X-Stream-User-Id in employeeAgentAuth.headers (athena-ai server/internal/api/agent_transport.go:30-38), taking the email from its employee record (access.Membership.Email, server/internal/access/access.go:85-88), on the server-side calls made before a consent (session create, connection create, start consent).

Tests

  • internal/auth: an end user's device never sets the email (user token, user token + server header, server token without the header, anonymous); a declared backend does.
  • TestAuthorizationsSuite: owner's own consent connects; another account is refused and stores nothing (no credentials, no account, no audit row); no email said is refused; chat consent path refused; reconnect checked by account, not email; app-owned not checked; control; only the backend's header is kept, latest wins.
  • ownerEmailRefusal unit test; owner_email manifest validation; Nessy consent at the fake reads sub/email.
  • Mutation checks: 18 mutations, all killed (details in the author report).

🤖 Generated with Claude Code

kanat and others added 3 commits October 21, 2026 13:23
The app's backend says which email its user signs in with in X-Stream-User-Email,
read from a server-side caller only and kept per user. A manifest that sets
owner_email refuses a user-owned connection's first consent, storing nothing,
unless the id_token's email is that one. nessy.yaml opts in (revision 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kanat and others added 4 commits October 21, 2026 14:51
…an end user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kanat
kanat marked this pull request as ready for review October 21, 2026 19:03
@kanat
kanat merged commit adc9d60 into accelerate Oct 21, 2026
11 checks passed
@kanat
kanat deleted the connectors/first-consent-account branch October 21, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant