Skip to content

Make OAuth tokens account-wide with per-request team selection - #182

Merged
s3rot0n1n merged 1 commit into
mainfrom
feat/account-wide-oauth
Nov 2, 2026
Merged

s3rot0n1n merged 1 commit into
mainfrom
feat/account-wide-oauth

Conversation

@clbotdev

@clbotdev clbotdev commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

OAuth tokens are now account-wide. One OAuth connection (MCP or REST) works in every team the user belongs to, limited to the user's role and permissions in each team. Plan and security notes: apps/api/docs/account-wide-oauth.md.

  • Team resolution: each request names its team: REST through X-Frontlit-Team-Id or /teams/:teamId, MCP through a teamId argument. resolveTeamContext (auth/team-context.ts) checks live membership on every call. A sole team is used when none is named; otherwise the request fails with team_required, which lists the user's teams.
  • MCP wrapper: the tool registry adds an optional teamId to every team-scoped tool and resolves the team before authorization. It removes teamId from handler args, because handlers spread args after the internal team ID. Results of mutating tools end with a line naming the team. The 90 tool handlers are unchanged.
  • Removed:
    • the OAuth team picker and its pages;
    • the team_id claim;
    • the token-issue-time vs membership-created check;
    • oauth_post_login_team_selections (migration 0014);
    • team-bound token revocation, which matched on referenceId and would have silently done nothing.
      Leaving a team is now enforced by the per-request membership check.
  • API keys stay bound to one team. A different teamId or header returns team_mismatch.
  • Untrusted content: tools that return text written by outsiders (tickets, contacts, documentation improvements, GitHub proposals and checks) say so in their descriptions, and the server instructions tell the assistant to act only in teams the user named.
  • Audit: authorization audit events record metadata.oauthClientId for OAuth requests. It is carried through AsyncLocalStorage set in the auth middleware.
  • api-contract: exports the team header name and error codes for the future SDK and CLI.
  • Docs: updated docs/architecture.md, team-memberships.md, and the developer docs (authentication, MCP, members).

Known gaps

  • Consent screen text is unchanged. @codelitdev/oauth-server-kit has no option for it, so "covers all your teams" needs an upstream change.
  • Third-party OAuth clients can only get MCP-audience tokens. This predates this PR. An SDK or CLI using REST with OAuth will need the REST resource allowed.

Test plan

  • vitest run (API): 768 passed. Includes new team-context, require-team and tool-registry tests, and integration tests on real handlers covering cross-team permissions, removal, rejoining, and team_mismatch.
  • tsc for the API and web, ESLint, Biome
  • Local browser run (isolated database):
    • OAuth consent shows no team picker, and the token has no team_id.
    • With one team, calls without teamId work.
    • After the user joins a second team read-only, the same token gets: team_required without a teamId; reads in that team succeed; writes there return insufficient_permission; writes in the owned team succeed; an unknown team returns invalid_team_id.
    • The audit row records oauthClientId.
    • After removal through the Members UI, the next call to that team is denied and the other team still works.
  • QA: API image codelit/frontlit:qa-acctoauth-8fc33c3 (linux/arm64, built locally). Web and sites use the same tag but point at the unchanged qa-ghinstall-2fddaad images. Deployed with infra/deploy.sh; migration 0014 applied. Repeated the browser run on QA with fresh users and the same results as locally: no team picker, no team_id in the token, sole-team calls work, and after joining a second team team_required, read allowed, write denied by permission, owned-team write allowed, invalid_team_id. After removal through the Members UI, the next call is denied and the other team keeps working. The audit oauthClientId was checked only locally, because QA has no endpoint for reading audit events.

OAuth tokens no longer bind a team at consent. Each request names its
team (REST: X-Frontlit-Team-Id or /teams/:teamId; MCP: a teamId tool
argument added centrally by the tool registry), and resolveTeamContext
checks live membership and permissions on every call. API keys stay bound
to one team and reject others with team_mismatch.

- Remove the OAuth team picker, team_id claim, and selection table
- Remove team-bound token revocation; membership checks deny removed teams
- Strip teamId from MCP handler args; name the team in mutating results
- Mark tools returning outside-written text as untrusted
- Record the OAuth client ID on authorization audit events
- Document the design, SDK/CLI notes, and security considerations
@s3rot0n1n
s3rot0n1n merged commit c30f42e into main Nov 2, 2026
2 of 3 checks passed
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.

2 participants