Skip to content

Commit 26ca745

Browse files
committed
feat: add custom scheme to handle cursor and other native tools
1 parent ba543d8 commit 26ca745

7 files changed

Lines changed: 197 additions & 14 deletions

File tree

config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Config struct {
1616
RedirectURIs string // Redirect URIs allowlist (single or comma-separated)
1717
FixedRedirectURI string // Optional fixed redirect URI used for proxying callbacks
1818
AllowedClientRedirectDomains string // Optional comma-separated list of domain suffixes allowed for client redirect URIs in fixed redirect mode (in addition to localhost)
19+
AllowedClientRedirectSchemes string // Optional comma-separated list of custom URI schemes allowed for client redirect URIs (e.g. "cursor,vscode") per RFC 8252
1920

2021
// OIDC configuration
2122
Issuer string
@@ -240,6 +241,13 @@ func (b *ConfigBuilder) WithAllowedClientRedirectDomains(domains string) *Config
240241
return b
241242
}
242243

244+
// WithAllowedClientRedirectSchemes sets allowed custom URI schemes for client redirect URIs
245+
// per RFC 8252 (OAuth 2.0 for Native Apps). Example: "cursor,vscode"
246+
func (b *ConfigBuilder) WithAllowedClientRedirectSchemes(schemes string) *ConfigBuilder {
247+
b.config.AllowedClientRedirectSchemes = schemes
248+
return b
249+
}
250+
243251
// WithIssuer sets the OIDC issuer
244252
func (b *ConfigBuilder) WithIssuer(issuer string) *ConfigBuilder {
245253
b.config.Issuer = issuer
@@ -364,6 +372,7 @@ func FromEnv() (*Config, error) {
364372
WithRedirectURIs(getEnv("OAUTH_REDIRECT_URIS", "")).
365373
WithFixedRedirectURI(getEnv("OAUTH_FIXED_REDIRECT_URI", "")).
366374
WithAllowedClientRedirectDomains(getEnv("OAUTH_ALLOWED_CLIENT_REDIRECT_DOMAINS", "")).
375+
WithAllowedClientRedirectSchemes(getEnv("OAUTH_ALLOWED_CLIENT_REDIRECT_SCHEMES", "")).
367376
WithIssuer(getEnv("OIDC_ISSUER", "")).
368377
WithAudience(getEnv("OIDC_AUDIENCE", "")).
369378
WithClientID(getEnv("OIDC_CLIENT_ID", "")).

config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ func TestConfigBuilder(t *testing.T) {
8989
},
9090
wantErr: true,
9191
},
92+
{
93+
name: "config with allowed client redirect schemes",
94+
buildFunc: func() (*Config, error) {
95+
return NewConfigBuilder().
96+
WithProvider("hmac").
97+
WithAudience("test-audience").
98+
WithJWTSecret([]byte("secret")).
99+
WithAllowedClientRedirectSchemes("cursor,vscode").
100+
Build()
101+
},
102+
wantURL: "http://localhost:8080",
103+
wantMode: "native",
104+
wantProvider: "hmac",
105+
},
92106
}
93107

94108
for _, tt := range tests {
@@ -110,6 +124,11 @@ func TestConfigBuilder(t *testing.T) {
110124
if cfg.Provider != tt.wantProvider {
111125
t.Errorf("Provider = %v, want %v", cfg.Provider, tt.wantProvider)
112126
}
127+
if tt.name == "config with allowed client redirect schemes" {
128+
if cfg.AllowedClientRedirectSchemes != "cursor,vscode" {
129+
t.Errorf("AllowedClientRedirectSchemes = %v, want %v", cfg.AllowedClientRedirectSchemes, "cursor,vscode")
130+
}
131+
}
113132
})
114133
}
115134
}

docs/CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Config struct {
2828
// Optional - Fixed Redirect Mode (for mcp-remote)
2929
FixedRedirectURI string // Single fixed redirect URI for proxying callbacks
3030
AllowedClientRedirectDomains string // Comma-separated domain suffixes allowed for client redirects
31+
AllowedClientRedirectSchemes string // Comma-separated custom URI schemes (e.g. "cursor,vscode") per RFC 8252
3132

3233
// Optional - Token Validation
3334
Scopes []string // OAuth scopes
@@ -68,6 +69,7 @@ See [SECURITY.md](SECURITY.md) for detailed migration guide.
6869
- **Option 1:** `RedirectURIs` - Comma-separated allowlist of exact URIs
6970
- **Option 2:** `FixedRedirectURI` - Single fixed URI for proxying callbacks
7071
- **Additional:** `AllowedClientRedirectDomains` - Domain suffixes allowed for client redirects (in addition to localhost)
72+
- **Additional:** `AllowedClientRedirectSchemes` - Custom URI schemes for native app redirect URIs per RFC 8252 (e.g. `cursor,vscode`)
7173

7274
**Mode Detection:**
7375
- `Mode = "native"` - Token validation only (ClientID not set)
@@ -137,6 +139,7 @@ _, oauthOption, _ := oauth.WithOAuth(mux, cfg)
137139
- `MCP_PORT` - Server port (default: 8080)
138140
- `HTTPS_CERT_FILE` - TLS cert file (enables HTTPS)
139141
- `HTTPS_KEY_FILE` - TLS key file (enables HTTPS)
142+
- `OAUTH_ALLOWED_CLIENT_REDIRECT_SCHEMES` - Custom URI schemes for native apps (e.g. "cursor,vscode")
140143

141144
**Benefits:**
142145

docs/SECURITY.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,31 @@ oauth.WithOAuth(mux, &oauth.Config{
387387
- No fragment allowed (per OAuth 2.0 spec)
388388
- Exact match validation (no wildcards)
389389

390+
### Custom URI Schemes (RFC 8252)
391+
392+
Native desktop applications like Cursor and VS Code use custom URI schemes (e.g. `cursor://`, `vscode://`) for OAuth callbacks per [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252).
393+
394+
Custom schemes are **opt-in** via `AllowedClientRedirectSchemes`. When not configured, only `http` (localhost) and `https` are accepted.
395+
396+
```go
397+
oauth.WithOAuth(mux, &oauth.Config{
398+
AllowedClientRedirectSchemes: "cursor,vscode",
399+
// ...
400+
})
401+
```
402+
403+
```
404+
ok: cursor://anysphere.cursor-mcp/oauth/callback (when "cursor" configured)
405+
ok: vscode://vscode.github-authentication/callback (when "vscode" configured)
406+
bad: cursor://anysphere.cursor-mcp/oauth/callback (when not configured)
407+
```
408+
409+
**Security considerations:**
410+
411+
- Any application on the device can register a custom URI scheme handler — PKCE is essential (already enforced by the OAuth flow)
412+
- Only allowlist schemes you explicitly trust
413+
- Custom scheme URIs bypass the domain suffix check (`AllowedClientRedirectDomains`) since they don't use traditional hostnames
414+
390415
---
391416

392417
## 🎫 Token Security

fixed_redirect_test.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func TestFixedRedirectModeLocalhostOnly(t *testing.T) {
6060
expectedError: "must not contain fragment",
6161
},
6262
{
63-
name: "Custom scheme rejected",
63+
name: "Custom scheme rejected when not configured",
6464
clientURI: "custom://localhost:8080/callback",
6565
shouldPass: false,
6666
expectedError: "Invalid redirect_uri scheme",
@@ -88,15 +88,73 @@ func TestFixedRedirectModeSecurityModel(t *testing.T) {
8888
t.Log("Fixed Redirect Mode Security Model:")
8989
t.Log("- Single OAUTH_REDIRECT_URI configured (no commas)")
9090
t.Log("- Server uses fixed URI to communicate with OAuth provider")
91-
t.Log("- Client redirect URIs MUST be localhost for security")
91+
t.Log("- Client redirect URIs MUST be localhost or an allowed custom scheme for security")
9292
t.Log("- HMAC-signed state prevents redirect URI tampering")
9393
t.Log("")
9494
t.Log("Attack Prevention:")
9595
t.Log("1. Open Redirect → Localhost-only restriction prevents external redirects")
9696
t.Log("2. State Tampering → HMAC signature verification prevents modification")
9797
t.Log("3. Code Theft → PKCE prevents token exchange without code_verifier")
9898
t.Log("4. HTTP Exposure → HTTPS required for non-localhost URIs")
99+
t.Log("5. Custom Schemes → Only explicitly configured schemes accepted (RFC 8252)")
99100
t.Log("")
100101
t.Log("Use Case: Development tools (MCP Inspector) running on localhost")
102+
t.Log("Use Case: Native apps (Cursor, VS Code) using custom URI schemes")
101103
t.Log("Production: Use allowlist mode instead")
102104
}
105+
106+
func TestFixedRedirectModeCustomSchemes(t *testing.T) {
107+
tests := []struct {
108+
name string
109+
schemes string
110+
clientURI string
111+
shouldPass bool
112+
}{
113+
{
114+
name: "Cursor scheme allowed when configured",
115+
schemes: "cursor",
116+
clientURI: "cursor://anysphere.cursor-mcp/oauth/callback",
117+
shouldPass: true,
118+
},
119+
{
120+
name: "Cursor scheme rejected when not configured",
121+
schemes: "",
122+
clientURI: "cursor://anysphere.cursor-mcp/oauth/callback",
123+
shouldPass: false,
124+
},
125+
{
126+
name: "VSCode scheme allowed when configured",
127+
schemes: "vscode",
128+
clientURI: "vscode://vscode.github-authentication/callback",
129+
shouldPass: true,
130+
},
131+
{
132+
name: "Multiple schemes configured",
133+
schemes: "cursor, vscode",
134+
clientURI: "cursor://anysphere.cursor-mcp/oauth/callback",
135+
shouldPass: true,
136+
},
137+
{
138+
name: "HTTP localhost still works with custom schemes configured",
139+
schemes: "cursor",
140+
clientURI: "http://localhost:8080/callback",
141+
shouldPass: true,
142+
},
143+
}
144+
145+
for _, tt := range tests {
146+
t.Run(tt.name, func(t *testing.T) {
147+
handler := &OAuth2Handler{
148+
config: &OAuth2Config{
149+
AllowedClientRedirectSchemes: tt.schemes,
150+
},
151+
logger: &defaultLogger{},
152+
}
153+
154+
got := handler.isAllowedClientRedirectURI(tt.clientURI)
155+
if got != tt.shouldPass {
156+
t.Errorf("isAllowedClientRedirectURI(%q) = %v, want %v (schemes=%q)", tt.clientURI, got, tt.shouldPass, tt.schemes)
157+
}
158+
})
159+
}
160+
}

handlers.go

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ type OAuth2Config struct {
5353
// that are allowed for client redirect URIs in fixed redirect mode (in addition to localhost).
5454
AllowedClientRedirectDomains string
5555

56+
// AllowedClientRedirectSchemes is an optional comma-separated list of custom URI schemes
57+
// allowed for client redirect URIs (e.g. "cursor,vscode") per RFC 8252.
58+
AllowedClientRedirectSchemes string
59+
5660
// OIDC configuration
5761
Issuer string
5862
Audience string
@@ -219,6 +223,7 @@ func NewOAuth2ConfigFromConfig(cfg *Config, version string) *OAuth2Config {
219223
RedirectURIs: cfg.RedirectURIs,
220224
FixedRedirectURI: cfg.FixedRedirectURI,
221225
AllowedClientRedirectDomains: cfg.AllowedClientRedirectDomains,
226+
AllowedClientRedirectSchemes: cfg.AllowedClientRedirectSchemes,
222227
Issuer: cfg.Issuer,
223228
Audience: cfg.Audience,
224229
ClientID: cfg.ClientID,
@@ -368,14 +373,14 @@ func (h *OAuth2Handler) HandleAuthorize(w http.ResponseWriter, r *http.Request)
368373
return
369374
}
370375

371-
// Additional security checks for client redirect URI
372-
if parsedURI.Scheme != "http" && parsedURI.Scheme != "https" {
373-
h.logger.Warn("SECURITY: Invalid redirect URI scheme: %s (must be http or https)", parsedURI.Scheme)
376+
// Additional security checks for client redirect URI scheme
377+
if !h.isAllowedScheme(parsedURI.Scheme) {
378+
h.logger.Warn("SECURITY: Invalid redirect URI scheme: %s (must be http, https, or an allowed custom scheme)", parsedURI.Scheme)
374379
http.Error(w, "Invalid redirect_uri scheme", http.StatusBadRequest)
375380
return
376381
}
377382

378-
// Enforce HTTPS for non-localhost URIs
383+
// Enforce HTTPS for non-localhost URIs (only applies to http scheme; custom schemes skip this)
379384
if parsedURI.Scheme == "http" && !isLocalhostURI(clientRedirectURI) {
380385
h.logger.Warn("SECURITY: HTTP redirect URI not allowed for non-localhost: %s", clientRedirectURI)
381386
http.Error(w, "HTTPS required for non-localhost redirect_uri", http.StatusBadRequest)
@@ -922,17 +927,22 @@ func (h *OAuth2Handler) isAllowedClientRedirectURI(uri string) bool {
922927
return true
923928
}
924929

925-
// For non-localhost URIs, require explicit domain suffix configuration
926-
if h.config.AllowedClientRedirectDomains == "" {
930+
parsedURI, err := url.Parse(uri)
931+
if err != nil {
927932
return false
928933
}
929934

930-
parsedURI, err := url.Parse(uri)
931-
if err != nil {
935+
// Allow explicitly configured custom schemes (e.g. cursor://, vscode://) per RFC 8252
936+
if h.isCustomScheme(parsedURI.Scheme) {
937+
return true
938+
}
939+
940+
// For non-localhost URIs with standard schemes, require explicit domain suffix configuration
941+
if h.config.AllowedClientRedirectDomains == "" {
932942
return false
933943
}
934944

935-
// Only allow HTTPS for non-localhost URIs
945+
// Only allow HTTPS for non-localhost URIs with standard schemes
936946
if parsedURI.Scheme != "https" {
937947
return false
938948
}
@@ -956,6 +966,28 @@ func (h *OAuth2Handler) isAllowedClientRedirectURI(uri string) bool {
956966
return false
957967
}
958968

969+
// isAllowedScheme returns true for http, https, or any explicitly configured custom scheme.
970+
func (h *OAuth2Handler) isAllowedScheme(scheme string) bool {
971+
if scheme == "http" || scheme == "https" {
972+
return true
973+
}
974+
return h.isCustomScheme(scheme)
975+
}
976+
977+
// isCustomScheme checks if scheme is in the configured AllowedClientRedirectSchemes list.
978+
func (h *OAuth2Handler) isCustomScheme(scheme string) bool {
979+
if h.config.AllowedClientRedirectSchemes == "" {
980+
return false
981+
}
982+
scheme = strings.ToLower(scheme)
983+
for _, s := range strings.Split(h.config.AllowedClientRedirectSchemes, ",") {
984+
if strings.TrimSpace(strings.ToLower(s)) == scheme {
985+
return true
986+
}
987+
}
988+
return false
989+
}
990+
959991
// isValidRedirectURI validates redirect URI against allowlist for security
960992
func (h *OAuth2Handler) isValidRedirectURI(uri string) bool {
961993
if h.config.RedirectURIs == "" {

security_test.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@ func TestRedirectURIValidation(t *testing.T) {
7777
func TestIsAllowedClientRedirectURI(t *testing.T) {
7878
tests := []struct {
7979
name string
80-
allowed string
80+
allowed string // AllowedClientRedirectDomains
81+
schemes string // AllowedClientRedirectSchemes
8182
uri string
8283
isAllowed bool
8384
}{
@@ -136,7 +137,7 @@ func TestIsAllowedClientRedirectURI(t *testing.T) {
136137
isAllowed: false,
137138
},
138139
{
139-
name: "Non-HTTPS scheme rejected",
140+
name: "Non-HTTPS scheme rejected when not configured",
140141
allowed: "example.com",
141142
uri: "custom://example.com/callback",
142143
isAllowed: false,
@@ -147,20 +148,56 @@ func TestIsAllowedClientRedirectURI(t *testing.T) {
147148
uri: "not-a-valid-uri",
148149
isAllowed: false,
149150
},
151+
{
152+
name: "Custom scheme allowed when configured",
153+
schemes: "cursor",
154+
uri: "cursor://anysphere.cursor-mcp/oauth/callback",
155+
isAllowed: true,
156+
},
157+
{
158+
name: "Custom scheme rejected when not configured",
159+
uri: "cursor://anysphere.cursor-mcp/oauth/callback",
160+
isAllowed: false,
161+
},
162+
{
163+
name: "Unconfigured custom scheme rejected",
164+
schemes: "vscode",
165+
uri: "cursor://anysphere.cursor-mcp/oauth/callback",
166+
isAllowed: false,
167+
},
168+
{
169+
name: "Multiple custom schemes - first match",
170+
schemes: "cursor, vscode",
171+
uri: "cursor://anysphere.cursor-mcp/oauth/callback",
172+
isAllowed: true,
173+
},
174+
{
175+
name: "Multiple custom schemes - second match",
176+
schemes: "cursor, vscode",
177+
uri: "vscode://vscode.github-authentication/callback",
178+
isAllowed: true,
179+
},
180+
{
181+
name: "Custom scheme case insensitive",
182+
schemes: "Cursor",
183+
uri: "cursor://anysphere.cursor-mcp/oauth/callback",
184+
isAllowed: true,
185+
},
150186
}
151187

152188
for _, tt := range tests {
153189
t.Run(tt.name, func(t *testing.T) {
154190
handler := &OAuth2Handler{
155191
config: &OAuth2Config{
156192
AllowedClientRedirectDomains: tt.allowed,
193+
AllowedClientRedirectSchemes: tt.schemes,
157194
},
158195
logger: &defaultLogger{},
159196
}
160197

161198
got := handler.isAllowedClientRedirectURI(tt.uri)
162199
if got != tt.isAllowed {
163-
t.Errorf("isAllowedClientRedirectURI(%q) = %v, want %v (allowed=%q)", tt.uri, got, tt.isAllowed, tt.allowed)
200+
t.Errorf("isAllowedClientRedirectURI(%q) = %v, want %v (domains=%q, schemes=%q)", tt.uri, got, tt.isAllowed, tt.allowed, tt.schemes)
164201
}
165202
})
166203
}

0 commit comments

Comments
 (0)