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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ http.ListenAndServe(":8080", handler)
- **Fast token caching** - 5-min cache, <5ms validation
- **Production ready** - Security hardened, battle-tested
- **Multiple providers** - HMAC, Okta, Google, Azure AD
- **Headless service tokens** - Optional asymmetric JWTs for non-interactive agents without changing your OAuth provider

---

## Service Tokens for Agents

Remote MCP servers can opt in to service-token auth for headless agents while keeping the existing human OAuth flow. Service tokens are asymmetric JWTs verified with a public key; the MCP server never needs the private key.

See [Service Tokens](docs/SERVICE-TOKENS.md) for the token contract, config fields, and rotation notes.

---

Expand Down
94 changes: 86 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package oauth

import (
"encoding/base64"
"fmt"

"github.com/Vungle/oauth-mcp-proxy/provider"
Expand Down Expand Up @@ -28,6 +29,13 @@ type Config struct {
// Security
JWTSecret []byte // For HMAC provider and state signing

// Service token settings for non-interactive clients
ServiceTokenEnabled bool
ServiceTokenIssuer string
ServiceTokenAudience string
ServiceTokenPublicKeyPEM string
ServiceTokenSubjectPrefix string

// Optional - Logging
// Logger allows custom logging implementation. If nil, uses default logger
// that outputs to log.Printf with level prefixes ([INFO], [ERROR], etc.).
Expand Down Expand Up @@ -76,6 +84,24 @@ func (c *Config) Validate() error {
return fmt.Errorf("audience is required")
}

if c.ServiceTokenEnabled {
if c.ServiceTokenSubjectPrefix == "" {
c.ServiceTokenSubjectPrefix = "svc-"
}
if c.ServiceTokenIssuer == "" {
return fmt.Errorf("service token issuer is required when service token auth is enabled")
}
if c.ServiceTokenAudience == "" {
return fmt.Errorf("service token audience is required when service token auth is enabled")
}
if c.ServiceTokenPublicKeyPEM == "" {
return fmt.Errorf("service token public key PEM is required when service token auth is enabled")
}
if c.Issuer != "" && c.ServiceTokenIssuer == c.Issuer {
return fmt.Errorf("service token issuer must be distinct from OAuth issuer")
}
}

// Validate proxy mode requirements
if c.Mode == "proxy" {
if c.ClientID == "" {
Expand Down Expand Up @@ -122,11 +148,15 @@ func SetupOAuth(cfg *Config) (provider.TokenValidator, error) {
func createValidator(cfg *Config, logger Logger) (provider.TokenValidator, error) {
// Convert root Config to provider.Config
providerCfg := &provider.Config{
Provider: cfg.Provider,
Issuer: cfg.Issuer,
Audience: cfg.Audience,
JWTSecret: cfg.JWTSecret,
Logger: logger,
Provider: cfg.Provider,
Issuer: cfg.Issuer,
Audience: cfg.Audience,
JWTSecret: cfg.JWTSecret,
ServiceTokenIssuer: cfg.ServiceTokenIssuer,
ServiceTokenAudience: cfg.ServiceTokenAudience,
ServiceTokenPublicKeyPEM: cfg.ServiceTokenPublicKeyPEM,
ServiceTokenSubjectPrefix: cfg.ServiceTokenSubjectPrefix,
Logger: logger,
}

var validator provider.TokenValidator
Expand All @@ -143,6 +173,14 @@ func createValidator(cfg *Config, logger Logger) (provider.TokenValidator, error
return nil, err
}

if cfg.ServiceTokenEnabled {
serviceTokenValidator := &provider.ServiceTokenValidator{}
if err := serviceTokenValidator.Initialize(providerCfg); err != nil {
return nil, err
}
validator = provider.NewRoutingValidator(validator, serviceTokenValidator, cfg.ServiceTokenIssuer)
}

return validator, nil
}

Expand Down Expand Up @@ -227,6 +265,16 @@ func (b *ConfigBuilder) WithJWTSecret(secret []byte) *ConfigBuilder {
return b
}

// WithServiceToken enables asymmetric service-token validation.
func (b *ConfigBuilder) WithServiceToken(issuer, audience, publicKeyPEM, subjectPrefix string) *ConfigBuilder {
b.config.ServiceTokenEnabled = true
b.config.ServiceTokenIssuer = issuer
b.config.ServiceTokenAudience = audience
b.config.ServiceTokenPublicKeyPEM = publicKeyPEM
b.config.ServiceTokenSubjectPrefix = subjectPrefix
return b
}

// WithLogger sets the logger
func (b *ConfigBuilder) WithLogger(logger Logger) *ConfigBuilder {
b.config.Logger = logger
Expand Down Expand Up @@ -290,8 +338,11 @@ func FromEnv() (*Config, error) {
}

jwtSecret := getEnv("JWT_SECRET", "")
serviceTokenEnabled := getEnv("SERVICE_TOKEN_ENABLED", "") == "true" ||
getEnv("SERVICE_TOKEN_ENABLED", "") == "1" ||
getEnv("SERVICE_TOKEN_ENABLED", "") == "yes"

return NewConfigBuilder().
builder := NewConfigBuilder().
WithMode(getEnv("OAUTH_MODE", "")).
WithProvider(getEnv("OAUTH_PROVIDER", "")).
WithRedirectURIs(getEnv("OAUTH_REDIRECT_URIS", "")).
Expand All @@ -301,6 +352,33 @@ func FromEnv() (*Config, error) {
WithClientID(getEnv("OIDC_CLIENT_ID", "")).
WithClientSecret(getEnv("OIDC_CLIENT_SECRET", "")).
WithServerURL(serverURL).
WithJWTSecret([]byte(jwtSecret)).
Build()
WithJWTSecret([]byte(jwtSecret))

if serviceTokenEnabled {
publicKeyPEM := getEnv("SERVICE_TOKEN_PUBLIC_KEY_PEM", "")
if publicKeyPEM == "" {
publicKeyPEM = decodeBase64Env("SERVICE_TOKEN_PUBLIC_KEY_PEM_B64")
}

builder.WithServiceToken(
getEnv("SERVICE_TOKEN_ISSUER", ""),
getEnv("SERVICE_TOKEN_AUDIENCE", ""),
publicKeyPEM,
getEnv("SERVICE_TOKEN_SUBJECT_PREFIX", ""),
)
}

return builder.Build()
}

func decodeBase64Env(key string) string {
value := getEnv(key, "")
if value == "" {
return ""
}
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return ""
}
return string(decoded)
}
35 changes: 35 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ type Config struct {
ServerURL string // Your server's public URL
RedirectURIs string // Allowed redirect URIs

// Optional - Service tokens for non-interactive clients
ServiceTokenEnabled bool
ServiceTokenIssuer string
ServiceTokenAudience string
ServiceTokenPublicKeyPEM string
ServiceTokenSubjectPrefix string // default: "svc-"

// Optional - Logging
Logger Logger // Custom logger implementation
}
Expand Down Expand Up @@ -93,6 +100,12 @@ _, oauthOption, _ := oauth.WithOAuth(mux, cfg)
- `MCP_PORT` - Server port (default: 8080)
- `HTTPS_CERT_FILE` - TLS cert file (enables HTTPS)
- `HTTPS_KEY_FILE` - TLS key file (enables HTTPS)
- `SERVICE_TOKEN_ENABLED` - Enables asymmetric service-token validation
- `SERVICE_TOKEN_ISSUER` - Expected service-token issuer
- `SERVICE_TOKEN_AUDIENCE` - Expected service-token audience
- `SERVICE_TOKEN_PUBLIC_KEY_PEM` - Public key PEM for service-token verification
- `SERVICE_TOKEN_PUBLIC_KEY_PEM_B64` - Base64-encoded public key PEM
- `SERVICE_TOKEN_SUBJECT_PREFIX` - Required service-token subject prefix (default: `svc-`)

**Benefits:**

Expand All @@ -118,6 +131,28 @@ Provider: "okta" // Use Okta OIDC validation

**See:** [Provider Guides](providers/) for setup instructions

## Optional Service Tokens

Service-token auth runs alongside the configured OAuth provider. It lets headless agents authenticate with an asymmetric JWT instead of an interactive browser OAuth flow.

```go
cfg := &oauth.Config{
Provider: "okta",
Issuer: "https://company.okta.com",
Audience: "https://company.okta.com",

ServiceTokenEnabled: true,
ServiceTokenIssuer: "agent-auth-service",
ServiceTokenAudience: "api://example-mcp-server",
ServiceTokenPublicKeyPEM: publicKeyPEM,
ServiceTokenSubjectPrefix: "svc-",
}
```

Use `SERVICE_TOKEN_PUBLIC_KEY_PEM_B64` for Kubernetes/Terraform values to avoid multiline PEM escaping issues.

**See:** [Service Tokens](SERVICE-TOKENS.md) for token claims, supported algorithms, and key handling.

### Audience

**Type:** `string`
Expand Down
74 changes: 74 additions & 0 deletions docs/SERVICE-TOKENS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Service Tokens

Service tokens let non-interactive agents call an MCP server without a browser OAuth flow. They are optional and run alongside the existing OAuth/OIDC provider.

## Token Contract

Service tokens are asymmetric JWTs. The MCP server verifies them with a public key; it never receives the private key.

Required claims:

- `iss`: must match `ServiceTokenIssuer`
- `aud`: must match `ServiceTokenAudience`
- `sub`: must start with `ServiceTokenSubjectPrefix` (default: `svc-`)
- `exp`: required; expired tokens are rejected

Supported signing algorithms:

- `EdDSA` with an Ed25519 public key
- `RS256` with an RSA public key

Do not use HMAC/HS256 for service tokens. HMAC requires the MCP server to hold a shared secret that can also mint tokens.

## Configuration

```go
oauthServer, oauthOption, err := mark3labs.WithOAuth(mux, &oauth.Config{
Provider: "okta",
Issuer: "https://your-company.okta.com",
Audience: "https://your-company.okta.com",

ServiceTokenEnabled: true,
ServiceTokenIssuer: "agent-auth-service",
ServiceTokenAudience: "api://example-mcp-server",
ServiceTokenPublicKeyPEM: publicKeyPEM,
ServiceTokenSubjectPrefix: "svc-",
})
```

`ServiceTokenPublicKeyPEM` should contain only a public key. Keep the matching private key in a separate, restricted secret store used only by the token minting workflow.

## Environment Variables

`FromEnv()` also supports:

```text
SERVICE_TOKEN_ENABLED=true
SERVICE_TOKEN_ISSUER=agent-auth-service
SERVICE_TOKEN_AUDIENCE=api://example-mcp-server
SERVICE_TOKEN_PUBLIC_KEY_PEM=<PEM public key>
SERVICE_TOKEN_PUBLIC_KEY_PEM_B64=<base64-encoded PEM public key>
SERVICE_TOKEN_SUBJECT_PREFIX=svc-
```

Use `SERVICE_TOKEN_PUBLIC_KEY_PEM_B64` for Kubernetes and Terraform values to avoid multiline PEM escaping issues.

## Validation Flow

The validator first peeks at the unsigned `iss` claim only to choose the validator:

- `iss == ServiceTokenIssuer`: validate with the service-token public key and required claims.
- Any other issuer: validate with the configured OAuth/OIDC provider.

The peeked claim is not trusted for authorization. If a token claims the service issuer but fails service-token validation, it is rejected and does not fall back to the OAuth provider.

## Key Rotation

First version supports one active public key. Rotation is:

1. Mint new tokens with a new private key.
2. Deploy the matching new public key to MCP servers.
3. Replace agent tokens.
4. Stop accepting the old public key.

Keep service-token TTLs short enough that this rotation window is acceptable.
9 changes: 5 additions & 4 deletions middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ func (s *Server) Middleware() func(server.ToolHandlerFunc) server.ToolHandlerFun
return nil, fmt.Errorf("authentication failed: %w", err)
}

// Cache the validation result (expire in 5 minutes)
expiresAt := time.Now().Add(5 * time.Minute)
s.cache.setCachedToken(tokenHash, user, expiresAt)
// Cache the validation result, but never beyond the token's own expiry.
if expiresAt, ok := cacheExpiresAtForToken(tokenString, time.Now()); ok {
s.cache.setCachedToken(tokenHash, user, expiresAt)
}

// Add user to context for downstream handlers
ctx = context.WithValue(ctx, userContextKey, user)
s.logger.Info("Authenticated user %s for tool: %s (cached for 5 minutes)", user.Username, req.Params.Name)
s.logger.Info("Authenticated user %s for tool: %s", user.Username, req.Params.Name)

return next(ctx, req)
}
Expand Down
34 changes: 31 additions & 3 deletions oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import (
"time"

"github.com/Vungle/oauth-mcp-proxy/provider"
"github.com/golang-jwt/jwt/v5"
mcpserver "github.com/mark3labs/mcp-go/server"
)

const tokenCacheTTL = 5 * time.Minute

// Server represents an OAuth authentication server instance.
// Each Server maintains its own token cache and validator, allowing
// multiple independent OAuth configurations in the same application.
Expand Down Expand Up @@ -126,13 +129,38 @@ func (s *Server) ValidateTokenCached(ctx context.Context, token string) (*User,
return nil, fmt.Errorf("authentication failed: %w", err)
}

expiresAt := time.Now().Add(5 * time.Minute)
s.cache.setCachedToken(tokenHash, user, expiresAt)
now := time.Now()
if expiresAt, ok := cacheExpiresAtForToken(token, now); ok {
s.cache.setCachedToken(tokenHash, user, expiresAt)
s.logger.Info("Authenticated user %s (cached until %s)", user.Username, expiresAt.Format(time.RFC3339))
} else {
s.logger.Info("Authenticated user %s (not cached because token is expired)", user.Username)
}

s.logger.Info("Authenticated user %s (cached for 5 minutes)", user.Username)
return user, nil
}

func cacheExpiresAtForToken(tokenString string, now time.Time) (time.Time, bool) {
expiresAt := now.Add(tokenCacheTTL)

tokenString = strings.TrimPrefix(tokenString, "Bearer ")
claims := jwt.RegisteredClaims{}
if _, _, err := jwt.NewParser().ParseUnverified(tokenString, &claims); err != nil {
return expiresAt, true
}
if claims.ExpiresAt == nil {
return expiresAt, true
}
if !claims.ExpiresAt.After(now) {
return time.Time{}, false
}
if claims.ExpiresAt.Before(expiresAt) {
return claims.ExpiresAt.Time, true
}

return expiresAt, true
}

// GetAuthorizationServerMetadataURL returns the OAuth 2.0 authorization server metadata URL
func (s *Server) GetAuthorizationServerMetadataURL() string {
return fmt.Sprintf("%s/.well-known/oauth-authorization-server", s.config.ServerURL)
Expand Down
Loading
Loading