Skip to content

feat(security): implement OAuth2 authorization server with database s…#18

Merged
warkanum merged 4 commits intomainfrom
feature-auth-mcp
Apr 9, 2026
Merged

feat(security): implement OAuth2 authorization server with database s…#18
warkanum merged 4 commits intomainfrom
feature-auth-mcp

Conversation

@warkanum
Copy link
Copy Markdown
Member

@warkanum warkanum commented Apr 7, 2026

…upport

  • Add OAuthServer for handling OAuth2 flows including authorization, token exchange, and client registration.
  • Introduce DatabaseAuthenticator for persisting clients and authorization codes.
  • Implement SQL procedures for client registration, code saving, and token introspection.
  • Support for external OAuth2 providers and PKCE (Proof Key for Code Exchange).

…upport

- Add OAuthServer for handling OAuth2 flows including authorization, token exchange, and client registration.
- Introduce DatabaseAuthenticator for persisting clients and authorization codes.
- Implement SQL procedures for client registration, code saving, and token introspection.
- Support for external OAuth2 providers and PKCE (Proof Key for Code Exchange).
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements an OAuth 2.1 + PKCE authorization server within pkg/security, adds database persistence (clients, auth codes, token introspection/revocation) via stored procedures, and wires the server into pkg/resolvemcp so MCP clients can auto-discover and authenticate.

Changes:

  • Added security.OAuthServer HTTP handlers for metadata, dynamic client registration, authorization, token exchange, revocation, and introspection.
  • Introduced DatabaseAuthenticator persistence methods + SQL schema additions for OAuth clients/codes and token introspection/revocation.
  • Integrated the OAuth server into resolvemcp.Handler and expanded documentation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 20 comments.

Show a summary per file
File Description
pkg/security/sql_names.go Adds SQLNames entries for new OAuth server stored procedures.
pkg/security/README.md Documents OAuthServer endpoints, config, persistence, and usage patterns.
pkg/security/oauth_server.go New OAuth 2.1 + PKCE authorization server implementation and HTTP routing.
pkg/security/oauth_server_db.go Adds DatabaseAuthenticator methods for OAuth server persistence/introspection/revocation.
pkg/security/database_schema.sql Adds oauth_clients/oauth_codes tables and stored procedures for OAuth server persistence + introspection/revocation.
pkg/resolvemcp/README.md Documents MCP OAuth2 auth setup and modes for resolvemcp.
pkg/resolvemcp/oauth2.go Adds handler wiring for OAuth server + legacy OAuth2 cookie-flow routes and auth-wrapped transports.
pkg/resolvemcp/oauth2_server.go Adds EnableOAuthServer and provider registration on the resolvemcp.Handler.
pkg/resolvemcp/handler.go Extends Handler struct to store OAuth server + OAuth2 registrations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +816 to +822
func oauthSliceContains(slice []string, s string) bool {
for _, v := range slice {
if strings.EqualFold(v, s) {
return true
}
}
return false
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

redirect_uri matching should be an exact, case-sensitive string comparison per OAuth2 (RFC 6749). Using strings.EqualFold in oauthSliceContains can allow non-identical redirect URIs (e.g., path case changes) to pass validation, weakening redirect URI verification.

Copilot uses AI. Check for mistakes.
Comment on lines +825 to +841
func writeOAuthToken(w http.ResponseWriter, accessToken, refreshToken string, scopes []string) {
resp := map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 86400,
}
if refreshToken != "" {
resp["refresh_token"] = refreshToken
}
if len(scopes) > 0 {
resp["scope"] = strings.Join(scopes, " ")
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

writeOAuthToken hardcodes expires_in=86400, ignoring OAuthServerConfig.AccessTokenTTL (and any underlying session/token TTL). This can cause clients to cache tokens longer/shorter than they are actually valid; compute expires_in from the configured TTL or the authenticated session’s expiry.

Copilot uses AI. Check for mistakes.
CodeChallengeMethod string
ProviderName string // empty = password login
ExpiresAt time.Time
SessionToken string // set after authentication completes
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

pendingAuth only persists SessionToken and Scopes. Since OAuth2HandleCallback/Login can return a RefreshToken, the authorization_code exchange currently cannot return a refresh_token and the refresh_token grant becomes unusable. Persist the refresh token (or enough info to mint/lookup one) alongside the auth code and return it from /oauth/token.

Suggested change
SessionToken string // set after authentication completes
SessionToken string // set after authentication completes
RefreshToken string // set after authentication completes when refresh tokens are issued

Copilot uses AI. Check for mistakes.
Comment on lines +673 to +676
if s.auth != nil {
s.auth.OAuthRevokeToken(r.Context(), token) //nolint:errcheck
}
w.WriteHeader(http.StatusOK)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

revokeHandler only revokes when s.auth != nil. In external-provider-only mode (auth passed as nil), /oauth/revoke becomes a no-op even though issued access tokens are local session tokens stored in DB by provider authenticators. Consider allowing revocation via a persistence authenticator (e.g., first provider auth) or require a non-nil DB auth even when using only external providers.

Copilot uses AI. Check for mistakes.
Comment on lines +696 to +707
if token == "" || s.auth == nil {
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
return
}

info, err := s.auth.OAuthIntrospectToken(r.Context(), token)
if err != nil {
w.Write([]byte(`{"active":false}`)) //nolint:errcheck
return
}
json.NewEncoder(w).Encode(info) //nolint:errcheck
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

introspectHandler returns {"active":false} whenever s.auth == nil. In external-provider-only mode, this makes RFC 7662 introspection unusable even for valid session tokens created by external providers. Consider routing introspection to a configured persistence DatabaseAuthenticator (or a provider authenticator) rather than requiring s.auth.

Copilot uses AI. Check for mistakes.
//
// auth := security.NewGoogleAuthenticator(...)
// handler.RegisterOAuth2(auth, cfg)
// handler.EnableOAuthServer(resolvemcp.OAuthServerConfig{Issuer: "https://api.example.com"})
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The HTTPHandler doc comment example calls EnableOAuthServer with resolvemcp.OAuthServerConfig, but the actual API takes security.OAuthServerConfig. This is misleading for users and will not compile as written; please update the example type/package reference.

Suggested change
// handler.EnableOAuthServer(resolvemcp.OAuthServerConfig{Issuer: "https://api.example.com"})
// handler.EnableOAuthServer(security.OAuthServerConfig{Issuer: "https://api.example.com"})

Copilot uses AI. Check for mistakes.
Comment on lines +1418 to +1421
CREATE TABLE IF NOT EXISTS oauth_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

oauth_codes.client_id has a FOREIGN KEY to oauth_clients(client_id). This means codes cannot be persisted unless the corresponding client row exists in oauth_clients. If the application allows PersistCodes without PersistClients (as OAuthServerConfig does), authorization will fail at code-save time due to the FK constraint. Consider documenting/enforcing that PersistCodes requires PersistClients, or relaxing the FK if you intend codes to outlive in-memory client registrations.

Suggested change
CREATE TABLE IF NOT EXISTS oauth_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
-- Note: client_id is stored without a foreign key so codes can be persisted even
-- when OAuth clients are managed in memory rather than persisted in oauth_clients.
CREATE TABLE IF NOT EXISTS oauth_codes (
id SERIAL PRIMARY KEY,
code VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,

Copilot uses AI. Check for mistakes.
ProviderCallbackPath string

// LoginTitle is shown on the built-in login form when the server acts as its own
// identity provider. Defaults to "MCP Login".
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

OAuthServerConfig.LoginTitle doc comment says it defaults to "MCP Login", but NewOAuthServer sets it to "Sign in". Please align the comment (or the default) so users aren’t misled.

Suggested change
// identity provider. Defaults to "MCP Login".
// identity provider. Defaults to "Sign in".

Copilot uses AI. Check for mistakes.
Comment on lines +917 to +969
| `GET` | `/oauth/authorize` | OAuth 2.1 — start authorization / provider selection |
| `POST` | `/oauth/authorize` | OAuth 2.1 — login form submission |
| `POST` | `/oauth/token` | OAuth 2.1 — code exchange + refresh |
| `POST` | `/oauth/revoke` | RFC 7009 — token revocation |
| `POST` | `/oauth/introspect` | RFC 7662 — token introspection |
| `GET` | `{ProviderCallbackPath}` | External provider redirect target |

### Config

```go
cfg := security.OAuthServerConfig{
Issuer: "https://example.com", // Required — token issuer URL
ProviderCallbackPath: "/oauth/provider/callback", // External provider redirect target
LoginTitle: "My App Login", // HTML login page title
PersistClients: true, // Store clients in DB (multi-instance safe)
PersistCodes: true, // Store codes in DB (multi-instance safe)
DefaultScopes: []string{"openid", "profile"}, // Returned when no scope requested
AccessTokenTTL: time.Hour,
AuthCodeTTL: 5 * time.Minute,
}
```

| Field | Default | Notes |
|-------|---------|-------|
| `Issuer` | — | Required |
| `ProviderCallbackPath` | `/oauth/provider/callback` | |
| `LoginTitle` | `"Login"` | |
| `PersistClients` | `false` | Set `true` for multi-instance |
| `PersistCodes` | `false` | Set `true` for multi-instance |
| `DefaultScopes` | `nil` | |
| `AccessTokenTTL` | `1h` | |
| `AuthCodeTTL` | `5m` | |

### Operating Modes

**Mode 1 — Direct login (username/password form)**

Pass a `*DatabaseAuthenticator` to `NewOAuthServer`. The server renders a login form at `GET /oauth/authorize` and issues tokens via the stored session after login.

```go
auth := security.NewDatabaseAuthenticator(db)
srv := security.NewOAuthServer(cfg, auth)
```

**Mode 2 — External provider federation**

Pass `nil` as auth and register external providers. The authorize page shows a provider selection UI.

```go
srv := security.NewOAuthServer(cfg, nil)
srv.RegisterExternalProvider(googleAuth, "google")
srv.RegisterExternalProvider(githubAuth, "github")
```
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

Docs say the authorize page “shows a provider selection UI” when external providers are registered, but oauth_server.go currently redirects immediately to the first (or specified) provider and never renders a selection page. Either implement the documented selection UI or update the docs to reflect the current redirect behavior (e.g., require a provider query param to choose).

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +143
// RegisterExternalProvider adds an external OAuth2 provider (Google, GitHub, Microsoft, etc.)
// that handles user authentication via redirect. The DatabaseAuthenticator must have been
// configured with WithOAuth2(providerName, ...) before calling this.
// Multiple providers can be registered; the first is used as the default.
func (s *OAuthServer) RegisterExternalProvider(auth *DatabaseAuthenticator, providerName string) {
s.providers = append(s.providers, externalProvider{auth: auth, providerName: providerName})
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

RegisterExternalProvider appends to s.providers without any synchronization, while request handlers read s.providers concurrently. If providers can be registered after the server starts serving, this is a data race. Either document/enforce “register providers before serving” or protect providers with the existing mutex (or a separate one).

Copilot uses AI. Check for mistakes.
@warkanum
Copy link
Copy Markdown
Member Author

warkanum commented Apr 9, 2026

@copilot Fix the issues you mentioned. Keep in mind, I did update code

Copilot AI and others added 2 commits April 9, 2026 14:04
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 9, 2026

@copilot Fix the issues you mentioned. Keep in mind, I did update code

All review issues have been addressed in commits 850ad2b and ca0545e:

  • Exact redirect_uri matching: oauthSliceContains now uses == instead of strings.EqualFold
  • expires_in from config: writeOAuthToken is now a method using s.cfg.AccessTokenTTL.Seconds()
  • RefreshToken persistence: Added RefreshToken field to pendingAuth and OAuthCode; it's stored through auth code issuance and returned from /oauth/token
  • Revoke/introspect in external-provider mode: Both handlers now fall back to the first provider's DatabaseAuthenticator when s.auth == nil (with proper mutex protection)
  • PersistCodes without PersistClients: Removed the FK constraint from oauth_codes.client_id in the SQL schema
  • Issuer normalization: strings.TrimSuffix trims one trailing slash in NewOAuthServer
  • Login form relative action: Changed action="/oauth/authorize" to action="authorize"
  • Goroutine shutdown: Added Close() method with a done channel; cleanupExpired uses a select loop
  • LoginTitle doc fix: Updated comment to say "Sign in" (matching the default)
  • RegisterExternalProvider race: Wrapped the append in s.mu.Lock()
  • SQL roles empty array: Uses NULLIF/COALESCE pattern returning [] instead of [""]
  • user_level in OAuthTokenInfo: Added UserLevel int field to match SQL introspection output
  • README defaults aligned: Config table updated to reflect actual defaults ("Sign in", ["openid","profile","email"], 24h, 2m)
  • Mode 2 docs updated: Both READMEs now recommend passing a DatabaseAuthenticator (not nil) for persistence/revoke/introspect; provider selection docs updated to reflect actual redirect behavior
  • Doc comment type fix: resolvemcp/oauth2.go now references security.OAuthServerConfig

@warkanum warkanum merged commit 1e89124 into main Apr 9, 2026
Copilot stopped work on behalf of warkanum due to an error April 9, 2026 14:18
@warkanum warkanum deleted the feature-auth-mcp branch April 9, 2026 14:18
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.

3 participants