Skip to content

Commit ae4a445

Browse files
dgellowclaude
andcommitted
feat: add workaround for Claude.ai persistent client ID issue
Claude.ai generates a single client ID per OAuth provider domain and reuses it forever. When client registrations are lost (server restart, storage cleared), Claude.ai has no mechanism to detect this and re-register, leaving users permanently locked out. This workaround auto-registers Claude.ai clients when missing, checking for: - Exact Claude.ai redirect URI: https://claude.ai/api/mcp/auth_callback - Claude.ai MCP endpoints: https://claude.ai/api/mcp/* Security note: While less secure than standard OAuth (redirect URI can be spoofed), this is necessary until Claude.ai implements proper client registration retry logic. Added comprehensive tests to ensure: - Only Claude.ai URLs trigger auto-registration - Fake domains like myfakeclaude.ai are rejected - Existing clients are not recreated TODO: Remove once Claude.ai fixes their client registration behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 5a87c83 commit ae4a445

File tree

2 files changed

+163
-0
lines changed

2 files changed

+163
-0
lines changed

internal/oauth/oauth.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,29 @@ func (s *Server) AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
216216
internal.LogError("Client not found: %v", err)
217217
}
218218

219+
// WORKAROUND: Claude.ai generates a single client ID per OAuth provider domain
220+
// and reuses it forever. If the client registration is lost (server restart,
221+
// storage cleared, etc), Claude.ai has no mechanism to detect this and re-register.
222+
// This auto-registers their client to prevent users from being permanently locked out.
223+
// TODO: Remove once Claude.ai implements proper client registration retry logic
224+
if clientID != "" &&
225+
(redirectURI == "https://claude.ai/api/mcp/auth_callback" ||
226+
strings.HasPrefix(redirectURI, "https://claude.ai/api/mcp/")) {
227+
228+
if _, err := s.storage.GetClient(ctx, clientID); err != nil {
229+
internal.LogWarn("Auto-registering Claude.ai client %s", clientID)
230+
231+
// Register Claude.ai's client with their parameters
232+
redirectURIs := []string{redirectURI}
233+
requestedScopes := strings.Fields(scopes)
234+
if len(requestedScopes) == 0 {
235+
requestedScopes = []string{"read", "write"}
236+
}
237+
238+
s.storage.CreateClient(clientID, redirectURIs, requestedScopes, s.config.Issuer)
239+
}
240+
}
241+
219242
// Parse and validate the authorization request
220243
ar, err := s.provider.NewAuthorizeRequest(ctx, r)
221244
if err != nil {

internal/oauth/oauth_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,143 @@ func TestAuthServiceArchitecture(t *testing.T) {
390390
}
391391
})
392392
}
393+
394+
// TestClaudeAIWorkaround tests the auto-registration workaround for Claude.ai
395+
func TestClaudeAIWorkaround(t *testing.T) {
396+
config := Config{
397+
Issuer: "https://test.example.com",
398+
TokenTTL: time.Hour,
399+
AllowedDomains: []string{"example.com"},
400+
GoogleClientID: "test-client-id",
401+
GoogleClientSecret: "test-client-secret",
402+
GoogleRedirectURI: "https://test.example.com/callback",
403+
JWTSecret: "test-secret-32-bytes-long-for-testing",
404+
EncryptionKey: "test-encryption-key-32-bytes-ok!",
405+
}
406+
407+
store := storage.NewMemoryStorage()
408+
server, err := NewServer(config, store)
409+
if err != nil {
410+
t.Fatalf("Failed to create OAuth server: %v", err)
411+
}
412+
413+
// Test cases for Claude.ai workaround
414+
tests := []struct {
415+
name string
416+
clientID string
417+
redirectURI string
418+
shouldAutoRegister bool
419+
}{
420+
{
421+
name: "Claude.ai auth callback - should auto-register",
422+
clientID: "claude-test-client-123",
423+
redirectURI: "https://claude.ai/api/mcp/auth_callback",
424+
shouldAutoRegister: true,
425+
},
426+
{
427+
name: "Claude.ai MCP endpoint - should auto-register",
428+
clientID: "claude-test-client-456",
429+
redirectURI: "https://claude.ai/api/mcp/something",
430+
shouldAutoRegister: true,
431+
},
432+
{
433+
name: "Fake Claude domain - should NOT auto-register",
434+
clientID: "fake-claude-client",
435+
redirectURI: "https://myfakeclaude.ai/api/mcp/auth_callback",
436+
shouldAutoRegister: false,
437+
},
438+
{
439+
name: "Non-Claude client - should NOT auto-register",
440+
clientID: "other-client",
441+
redirectURI: "https://example.com/callback",
442+
shouldAutoRegister: false,
443+
},
444+
}
445+
446+
for _, tt := range tests {
447+
t.Run(tt.name, func(t *testing.T) {
448+
// Ensure client doesn't exist
449+
_, err := store.GetClient(context.Background(), tt.clientID)
450+
if err == nil {
451+
t.Fatalf("Client %s should not exist initially", tt.clientID)
452+
}
453+
454+
// Create request
455+
req := httptest.NewRequest("GET", "/authorize", nil)
456+
q := req.URL.Query()
457+
q.Set("client_id", tt.clientID)
458+
q.Set("redirect_uri", tt.redirectURI)
459+
q.Set("response_type", "code")
460+
q.Set("scope", "read write")
461+
q.Set("state", "test-state-123")
462+
q.Set("code_challenge", "test-challenge")
463+
q.Set("code_challenge_method", "S256")
464+
req.URL.RawQuery = q.Encode()
465+
466+
w := httptest.NewRecorder()
467+
468+
// Call the handler
469+
server.AuthorizeHandler(w, req)
470+
471+
// Check if client was auto-registered
472+
_, err = store.GetClient(context.Background(), tt.clientID)
473+
if tt.shouldAutoRegister {
474+
if err != nil {
475+
t.Errorf("Claude.ai client should have been auto-registered but wasn't")
476+
}
477+
// Verify the auto-registered client has correct redirect URI
478+
client, _ := store.GetClient(context.Background(), tt.clientID)
479+
if len(client.GetRedirectURIs()) != 1 || client.GetRedirectURIs()[0] != tt.redirectURI {
480+
t.Errorf("Auto-registered client has wrong redirect URI: %v", client.GetRedirectURIs())
481+
}
482+
} else {
483+
if err == nil {
484+
t.Errorf("Non-Claude.ai client should NOT have been auto-registered but was")
485+
}
486+
}
487+
488+
// Clean up for next test
489+
if tt.shouldAutoRegister {
490+
// Remove the auto-registered client
491+
delete(store.MemoryStore.Clients, tt.clientID)
492+
}
493+
})
494+
}
495+
496+
// Test that existing Claude.ai clients are not re-created
497+
t.Run("Existing Claude.ai client - should not recreate", func(t *testing.T) {
498+
clientID := "existing-claude-client"
499+
redirectURI := "https://claude.ai/api/mcp/auth_callback"
500+
501+
// Pre-register client with specific scopes
502+
originalScopes := []string{"read", "write", "admin"}
503+
store.CreateClient(clientID, []string{redirectURI}, originalScopes, config.Issuer)
504+
505+
// Create request
506+
req := httptest.NewRequest("GET", "/authorize", nil)
507+
q := req.URL.Query()
508+
q.Set("client_id", clientID)
509+
q.Set("redirect_uri", redirectURI)
510+
q.Set("response_type", "code")
511+
q.Set("scope", "read") // Different scope than registered
512+
q.Set("state", "test-state-456")
513+
q.Set("code_challenge", "test-challenge")
514+
q.Set("code_challenge_method", "S256")
515+
req.URL.RawQuery = q.Encode()
516+
517+
w := httptest.NewRecorder()
518+
519+
// Call the handler
520+
server.AuthorizeHandler(w, req)
521+
522+
// Verify client still has original scopes (not overwritten)
523+
client, err := store.GetClient(context.Background(), clientID)
524+
if err != nil {
525+
t.Fatalf("Client should still exist: %v", err)
526+
}
527+
528+
if len(client.GetScopes()) != len(originalScopes) {
529+
t.Errorf("Client scopes were modified. Expected %v, got %v", originalScopes, client.GetScopes())
530+
}
531+
})
532+
}

0 commit comments

Comments
 (0)