Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/features/idp-token-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# IdP Subject Token Storage (Server Edition)

MCPProxy Server edition can persist the IdP (identity-provider) access and refresh
tokens obtained during a user's OAuth login so that downstream services can use them
for on-behalf-of (OBO) token exchange (RFC 8693, spec 074 TokenExchanger). This
feature is **off by default** and requires an encryption key to activate.

## Prerequisites

- Server edition (`go build -tags server`)
- A 32-byte, base64-encoded AES-256 master key

## Configuration

Two settings control the feature, both under the `teams` block:

```json
{
"teams": {
"enabled": true,
"store_idp_tokens": true,
"credential_encryption_key": "<base64-encoded 32-byte AES key>",
"oauth": { ... }
}
}
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `store_idp_tokens` | bool | `false` | Enable IdP subject token persistence |
| `credential_encryption_key` | string | `""` | Base64-encoded AES-256 master key for at-rest encryption |

### Environment variable override

`MCPPROXY_CRED_KEY` overrides `credential_encryption_key` at startup and is the
recommended way to supply the key in container or systemd deployments (keeps
secrets out of the config file):

```bash
export MCPPROXY_CRED_KEY="$(openssl rand -base64 32)"
```

The env var takes precedence over the config file value when both are set.

## Key generation

```bash
# Generate a fresh 32-byte key and base64-encode it
openssl rand -base64 32
# Example output: 7h3K...== (44 characters)
```

Store this value in a secret manager (Vault, AWS Secrets Manager, Kubernetes
Secret, etc.) and inject it as `MCPPROXY_CRED_KEY` at runtime.

## Security model

- Tokens are encrypted with **AES-256-GCM** before being written to BBolt
(`~/.mcpproxy/config.db`).
- The master key is never written to disk by MCPProxy itself; it lives only in
memory after startup.
- When the master key is absent or empty, `store_idp_tokens` has no effect: the
credential store is disabled and a warning is logged at each login. No tokens
are persisted and the feature degrades gracefully to the pre-feature behaviour.
- Stored tokens are scoped per user. One user's credentials cannot be read by
another user.

## Token lifecycle

1. **Login** — when a user completes the OAuth flow and `store_idp_tokens: true`,
the provider's `access_token` and `refresh_token` are encrypted and stored.
2. **Use** — `GetValidIDPSubjectToken` returns the stored access token if it is
valid and not within 60 s of expiry.
3. **Refresh** — when the access token is near-expiry, MCPProxy automatically
exchanges the refresh token for a new access token using the provider's token
endpoint. The refreshed token is re-persisted.
4. **Re-auth** — when no refresh token is available, or the refresh fails, the
user is required to sign in again (`ErrReauthRequired`).

## Operational notes

- **Key rotation** is not yet supported. Rotating the key requires clearing the
`user_upstream_credentials` BBolt bucket and asking all users to sign in again.
- If `store_idp_tokens` is disabled after tokens have been stored, the stored data
remains encrypted in the database but is never read. A future cleanup command
will be added to purge it.
- The refresh token is only available when the OAuth provider issues one. Google
and Microsoft both require explicit `offline_access` / `access_type=offline`
parameters, which MCPProxy adds automatically when `store_idp_tokens: true`.
165 changes: 165 additions & 0 deletions internal/teams/auth/idp_subject_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//go:build server

package auth

import (
"context"
"errors"
"strings"
"time"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/teams/broker"
)

// idpSubjectTokenType is the credential Type recorded for persisted IdP subject
// tokens. It mirrors the value documented on broker.UpstreamCredential.
const idpSubjectTokenType = "idp_subject_token"

// idpRefreshSkew is how far ahead of expiry a stored IdP subject token is
// considered "near-expiry" and proactively refreshed, so a token never goes
// stale mid-use (FR-005).
const idpRefreshSkew = 60 * time.Second

// ErrReauthRequired signals that no usable IdP subject token can be produced for
// the user: none is stored, the store is disabled, or the token expired and
// cannot be refreshed. Callers MUST re-authenticate the user rather than fall
// back to a stale token (FR-005).
var ErrReauthRequired = errors.New("idp subject token requires re-authentication")

// persistIDPSubjectToken stores the freshly-obtained provider token for userID
// when capture is enabled. It is best-effort and never returns an error: a
// disabled flag, disabled/absent store, or write failure leaves login behaving
// exactly as before (FR-004/FR-006).
func (h *OAuthHandler) persistIDPSubjectToken(userID string, tokenResp *TokenResponse) {
if h.config == nil || !h.config.StoreIDPTokens {
return // default-off: behave exactly as today
}
if tokenResp == nil {
return
}
if h.credStore == nil || !h.credStore.Enabled() {
h.logger.Warnw("teams.store_idp_tokens is enabled but the credential store is disabled; "+
"IdP subject token not persisted (set MCPPROXY_CRED_KEY or teams.credential_encryption_key)",
"user_id", userID)
return
}

cred := &broker.UpstreamCredential{
Type: idpSubjectTokenType,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
TokenType: tokenResp.TokenType,
ExpiresAt: expiryFromExpiresIn(tokenResp.ExpiresIn),
Scopes: splitScopes(tokenResp.Scope),
ObtainedVia: "login",
UpdatedAt: time.Now(),
}
if err := h.credStore.Put(userID, "", cred); err != nil {
h.logger.Errorw("failed to persist IdP subject token", "user_id", userID, "error", err)
return
}
h.logger.Debugw("persisted IdP subject token",
"user_id", userID, "has_refresh", tokenResp.RefreshToken != "")
}

// GetValidIDPSubjectToken returns a non-expired IdP subject token for the user,
// refreshing it via the provider's refresh_token grant when it is expired or
// near-expiry. It never returns a stale token: when no valid token can be
// produced (none stored, store disabled, expired-and-not-refreshable, or refresh
// failed) it returns ErrReauthRequired (FR-005). This is the prerequisite seam
// consumed by the credential resolver (Path A token exchange).
func (h *OAuthHandler) GetValidIDPSubjectToken(ctx context.Context, userID string) (*broker.UpstreamCredential, error) {
if h.credStore == nil || !h.credStore.Enabled() {
return nil, ErrReauthRequired
}

cred, err := h.credStore.Get(userID, "")
if err != nil {
// Absent or undecryptable -> require re-auth, never a stale token.
return nil, ErrReauthRequired
}

// Fast path: valid and not within the refresh skew window.
if cred.IsValid() && !cred.ExpiresWithin(idpRefreshSkew) {
return cred, nil
}

// Needs refresh. Without a refresh token, re-auth is the only safe option.
if cred.RefreshToken == "" {
return nil, ErrReauthRequired
}

if h.config == nil || h.config.OAuth == nil {
return nil, ErrReauthRequired
}
provider, err := GetProvider(h.config.OAuth.Provider, h.config.OAuth.TenantID)
if err != nil {
h.logger.Warnw("cannot refresh IdP subject token: provider lookup failed",
"user_id", userID, "error", err)
return nil, ErrReauthRequired
}

tokenResp, err := provider.RefreshAccessToken(ctx, cred.RefreshToken,
h.config.OAuth.ClientID, h.config.OAuth.ClientSecret)
if err != nil {
h.logger.Warnw("IdP subject token refresh failed; re-auth required",
"user_id", userID, "error", err)
return nil, ErrReauthRequired
}

refreshed := &broker.UpstreamCredential{
Type: idpSubjectTokenType,
AccessToken: tokenResp.AccessToken,
RefreshToken: firstNonEmpty(tokenResp.RefreshToken, cred.RefreshToken),
TokenType: firstNonEmpty(tokenResp.TokenType, cred.TokenType),
ExpiresAt: expiryFromExpiresIn(tokenResp.ExpiresIn),
Scopes: chooseScopes(tokenResp.Scope, cred.Scopes),
ObtainedVia: "token_refresh",
UpdatedAt: time.Now(),
}

// Re-persist the refreshed credential. A write failure is non-fatal: the
// in-hand token is still valid for this call.
if err := h.credStore.Put(userID, "", refreshed); err != nil {
h.logger.Warnw("failed to persist refreshed IdP subject token",
"user_id", userID, "error", err)
}
return refreshed, nil
}

// expiryFromExpiresIn converts an OAuth expires_in (seconds) into an absolute
// expiry. A non-positive value yields the zero time, matching the
// never-expiring convention used by UpstreamCredential.
func expiryFromExpiresIn(expiresIn int) time.Time {
if expiresIn <= 0 {
return time.Time{}
}
return time.Now().Add(time.Duration(expiresIn) * time.Second)
}

// splitScopes splits a space-delimited OAuth scope string into a slice. It
// returns nil for an empty input so the field is omitted when serialized.
func splitScopes(scope string) []string {
fields := strings.Fields(scope)
if len(fields) == 0 {
return nil
}
return fields
}

// chooseScopes prefers freshly-returned scopes, falling back to the previously
// stored scopes when the refresh response omits them.
func chooseScopes(scope string, prev []string) []string {
if s := splitScopes(scope); len(s) > 0 {
return s
}
return prev
}

// firstNonEmpty returns a if non-empty, otherwise b.
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
Loading
Loading