Skip to content

feat(teams): persist + refresh IdP subject token at login (spec 074, MCP-1036) - #601

Merged
Dumbris merged 4 commits into
mainfrom
074-t3-idp-subject-token
Jun 10, 2026
Merged

feat(teams): persist + refresh IdP subject token at login (spec 074, MCP-1036)#601
Dumbris merged 4 commits into
mainfrom
074-t3-idp-subject-token

Conversation

@Dumbris

@Dumbris Dumbris commented Jun 4, 2026

Copy link
Copy Markdown
Member

Summary

Spec 074 T3 (MCP-1036). Builds on T1 (#587 credential store) and T2 (#588 config), both merged.

Today HandleCallback fetches userinfo then discards the provider token. This PR captures it when teams.store_idp_tokens is enabled and adds a get-or-refresh seam that never serves a stale token.

What changed

  • Capture at login (oauth_handler.go): after code exchange + user upsert, when store_idp_tokens is on, persist the IdP access + refresh token encrypted (AES-256-GCM) via the credential store as an idp_subject_token record keyed by userID (empty serverKey). Best-effort — a write failure never breaks login.
  • Refresh seam (idp_subject_token.go): GetValidIDPSubjectToken(ctx, userID) returns a non-expired token, refreshing via the provider refresh_token grant when expired/near-expiry (60s skew). When no valid token can be produced — absent, store disabled, expired-and-not-refreshable, or refresh failed — it returns ErrReauthRequired, never a stale token (FR-005). This is the prerequisite consumed by the credential resolver (Path A, MCP-1039).
  • Provider (oauth_providers.go): OAuthProvider.RefreshAccessToken (refresh_token grant, RFC 6749 §6).
  • Wiring (teams/setup.go): construct the broker store from MCPPROXY_CRED_KEY / teams.credential_encryption_key and attach via SetCredentialStore.

Default-off (FR-006)

With store_idp_tokens false (default) or no encryption key configured, login behaves exactly as before — no storage, store constructed disabled, capture silently skipped.

Tests (TDD, -tags server)

  • capture-at-login stores encrypted record (enabled)
  • no storage when disabled
  • refresh near/at expiry re-mints + re-persists (incl. rotated refresh token)
  • expired + not refreshable → ErrReauthRequired
  • absent token → ErrReauthRequired
  • store disabled → ErrReauthRequired
  • valid token returned unchanged

Verification

  • go build -tags server ./cmd/mcpproxy ✅ and go build ./cmd/mcpproxy (personal unaffected) ✅
  • go test -tags server ./internal/teams/... -race ✅ (all packages)
  • gofmt/go vet clean; CI lint config clean on changed files

Docs

No docs change, matching T1/T2 precedent (neither shipped docs for these keys). The feature is server-edition, default-off, and not yet user-consumable until the resolver + header injection land (T6+). Consolidated spec-074 user docs belong with that completion.

Related #588, #587. Blocks MCP-1039 (T6 resolver).

Gate 3: opening for review/CI only — I do not merge.

…MCP-1036)

Today HandleCallback fetched userinfo then discarded the provider token. When
teams.store_idp_tokens is enabled, capture the IdP access + refresh token at
login and persist it encrypted (AES-256-GCM) via the credential store as an
idp_subject_token record keyed by userID (empty serverKey).

Adds GetValidIDPSubjectToken: returns a non-expired token, refreshing via the
provider refresh_token grant when expired/near-expiry; when no valid token can
be produced (absent, store disabled, expired-and-not-refreshable, or refresh
failed) it returns ErrReauthRequired — never a stale token. This is the
prerequisite seam consumed by the credential resolver (Path A token exchange,
MCP-1039).

Default-off: with store_idp_tokens false (default) or no encryption key, login
behaves exactly as before — no storage. Persist is best-effort: a write failure
never breaks login.

- OAuthProvider.RefreshAccessToken: refresh_token grant (RFC 6749 §6)
- OAuthHandler.credStore + SetCredentialStore; wired in teams/setup.go
- TDD: capture-when-enabled, no-storage-when-disabled, refresh-near-expiry,
  expired-not-refreshable -> re-auth, absent -> re-auth, store-disabled -> re-auth

FR-004, FR-005, FR-006. Related #588 (T2 config), #587 (T1 store).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 4, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 29f9bd8
Status: ✅  Deploy successful!
Preview URL: https://16c1d1f8.mcpproxy-docs.pages.dev
Branch Preview URL: https://074-t3-idp-subject-token.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: 074-t3-idp-subject-token

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (14 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (24 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (21 MB)
  • installer-dmg-darwin-arm64 (19 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 27271359858 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris and others added 2 commits June 9, 2026 10:04
When teams.store_idp_tokens is on, the login authorization request must ask
providers for a durable refresh token so GetValidIDPSubjectToken can actually
refresh instead of returning ErrReauthRequired after expiry (Codex finding on
PR #601).

Provider-specific contracts (FR-006 default-off — login unchanged otherwise):
- Google:    access_type=offline + prompt=consent (OfflineAuthParams)
- Microsoft: offline_access scope (OfflineAccessScopes)
- GitHub:    unchanged (no refresh-token mechanism in scope)

BuildAuthURL gains an offlineAccess bool; caller (HandleLogin) sets it from
config.StoreIDPTokens. New tests:
- TestBuildAuthURL_OfflineAccess_Google / _Microsoft
- TestHandleLogin_RequestsOfflineAccess (default-off assertion added to
  TestHandleLogin_Redirects)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…tial_encryption_key)

Satisfies ENG-9 requirement from Codex R2 review on PR #601: document the
operator/security model introduced by idp_subject_token.go and setup.go:
- store_idp_tokens config field (default off)
- credential_encryption_key / MCPPROXY_CRED_KEY env override
- AES-256-GCM at-rest encryption model, key generation, key rotation caveat
- Token lifecycle (login → use → refresh → re-auth)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@Dumbris

Dumbris commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

CEO Review — MCP-1801 Final Sign-off (head 49339c3)

All R1 blockers resolved:

  • Docs (ENG-9): docs/features/idp-token-storage.md added in 49339c3 — covers store_idp_tokens (default off), credential_encryption_key/MCPPROXY_CRED_KEY env override, AES-256-GCM at-rest encryption, key generation, token lifecycle, and graceful degradation when key is absent.
  • CI: all checks green including Windows unit tests (previously timing out in internal/runtime).

This PR is clear for merge.

@Dumbris
Dumbris enabled auto-merge (squash) June 10, 2026 09:53
CodexReviewer caught that idp-token-storage.md named the key-rotation cleanup
bucket as 'upstream_credentials' but the actual store is 'user_upstream_credentials'
(internal/teams/broker/bbolt_aes.go:28) — following the doc would clear the wrong
bucket and leave IdP tokens behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@mcpproxy-gatekeeper mcpproxy-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gatekeeper approval — Codex review verdict: ACCEPT.

This approval is posted automatically by the MCPProxy Gatekeeper App on behalf of the Codex reviewer (verdict of record lives in the Paperclip review thread). Author≠approver satisfied; QA + CI gates enforced separately.

Auto-approved per Model B (MCP-1249).

@Dumbris
Dumbris merged commit bc6c96f into main Jun 10, 2026
53 of 54 checks passed
Dumbris added a commit that referenced this pull request Jun 15, 2026
…am (spec 074, MCP-1039) (#688)

* feat(broker): CredentialResolver — per-user-only ordering + policy seam (spec 074, MCP-1039)

Add the per-user credential resolver (T6) that selects which brokered
credential to inject on a proxied request. Strict per-user-only ordering
(FR-013/FR-014), with no shared or static fallback:

  1. valid cached per-user credential (refreshed if near-expiry);
  2. else token-exchange / Entra OBO from the stored IdP subject token;
  3. else, for oauth_connect upstreams the user has not connected, an
     actionable NotConnectedError carrying the connect URL;
  4. else ErrNoCredential.

- Single-flight coalescing per (user, server) so concurrent acquisitions
  do not trigger duplicate upstream token flows (golang.org/x/sync).
- PolicyHook seam (FR-015) evaluated per call before a credential is
  returned; ships with an allow-all default (no policy engine yet).
- Unauthenticated callers and a disabled store are rejected up front.
- Exchanger/Connector/ConnectorProvider interfaces decouple the resolver
  from the concrete TokenExchanger (T4) and OAuthConnector (T5); both
  satisfy the interfaces via compile-time assertions.

TDD: each ordering branch, near-expiry refresh (token-exchange + connect),
unconnected -> connect-URL error, unauthenticated reject, store-disabled,
policy-denied, no-static-fallback, and single-flight under -race.

Related: spec 074 (MCP-1033). Builds on T3 (#601), T4 (#600), T5 (#602), all merged to main.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(broker): address review on CredentialResolver — singleflight ctx, double-exchange, reconnect error

Review (MCP-2490 / Critic) on PR #688:

- must-fix: detach the caller's context inside the single-flight closure with
  context.WithoutCancel. The flight runs the acquisition once for all co-pending
  callers; inheriting the first caller's cancellation let a single client
  disconnect/timeout broadcast ctx.Err() to every waiter for the same
  (user, server). Per-caller cancellation still applies at the policy/return
  layer (caller's original ctx).
- advisory: collapse the per-mode acquire/refresh paths so a near-expiry
  token-exchange miss no longer calls Exchange twice (refresh-then-fallthrough);
  a single Exchange now covers both cache-miss and near-expiry.
- advisory: an already-connected oauth_connect user whose refresh fails now gets
  an actionable reconnect error (NotConnectedError.Reason) instead of a
  misleading "never connected" message; the connect URL is still surfaced.
- advisory: document that the Exchanger (T4) and Connector (T5) persist their
  results themselves, so the resolver never calls store.Put.

Tests: add single-flight caller-cancellation detach, no-double-exchange on
near-expiry failure, and connect-flow refresh-fail -> reconnect. Full broker
suite green under -tags server -race; golangci-lint v2.5.0 clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs(auth-broker): document per-user credential resolution ordering (spec 074, MCP-1039)

Add a "Credential resolution" section describing the resolver's strict
per-user-only ordering (cached/refresh -> token-exchange/OBO -> actionable
connect-URL error -> no-credential), the no-shared/static-fallback guarantee,
single-flight coalescing, and the policy-decision seam. Keeps the feature doc
consistent with the CredentialResolver added in this PR (review follow-up).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
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