Skip to content

Commit 1ca3c8f

Browse files
committed
Refactor OAuth structure for better separation of concerns
Move OAuth code into separate packages based on responsibility: auth package for inbound authentication from Claude, services package for outbound authentication to external services. Separate HTTP handlers from business logic for cleaner architecture.
1 parent c2bba04 commit 1ca3c8f

16 files changed

+1190
-1181
lines changed

internal/oauth/browser.go renamed to internal/auth/browser.go

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package oauth
1+
package auth
22

33
import (
44
"context"
@@ -28,7 +28,7 @@ func (s *Server) SSOMiddleware() func(http.Handler) http.Handler {
2828
if err != nil {
2929
// No cookie, redirect directly to Google OAuth
3030
state := s.generateBrowserState(r.URL.String())
31-
googleURL := s.authService.googleAuthURL(state)
31+
googleURL := s.authService.GoogleAuthURL(state)
3232
http.Redirect(w, r, googleURL, http.StatusFound)
3333
return
3434
}
@@ -40,7 +40,7 @@ func (s *Server) SSOMiddleware() func(http.Handler) http.Handler {
4040
log.LogDebug("Invalid session cookie: %v", err)
4141
cookie.ClearSession(w) // Clear bad cookie
4242
state := s.generateBrowserState(r.URL.String())
43-
googleURL := s.authService.googleAuthURL(state)
43+
googleURL := s.authService.GoogleAuthURL(state)
4444
http.Redirect(w, r, googleURL, http.StatusFound)
4545
return
4646
}
@@ -61,7 +61,7 @@ func (s *Server) SSOMiddleware() func(http.Handler) http.Handler {
6161
cookie.ClearSession(w)
6262
// Redirect directly to Google OAuth
6363
state := s.generateBrowserState(r.URL.String())
64-
googleURL := s.authService.googleAuthURL(state)
64+
googleURL := s.authService.GoogleAuthURL(state)
6565
http.Redirect(w, r, googleURL, http.StatusFound)
6666
return
6767
}
@@ -86,25 +86,3 @@ func (s *Server) generateBrowserState(returnURL string) string {
8686
// Format: "browser:nonce:signature:returnURL"
8787
return fmt.Sprintf("browser:%s:%s:%s", nonce, signature, returnURL)
8888
}
89-
90-
// setBrowserSessionCookie sets an encrypted session cookie for browser-based authentication
91-
func (s *Server) setBrowserSessionCookie(w http.ResponseWriter, userEmail string) error {
92-
sessionData := SessionData{
93-
Email: userEmail,
94-
Expires: time.Now().Add(s.config.SessionDuration),
95-
}
96-
97-
jsonData, err := json.Marshal(sessionData)
98-
if err != nil {
99-
return fmt.Errorf("failed to marshal session data: %w", err)
100-
}
101-
102-
encrypted, err := s.sessionEncryptor.Encrypt(string(jsonData))
103-
if err != nil {
104-
return fmt.Errorf("failed to encrypt session: %w", err)
105-
}
106-
107-
cookie.SetSession(w, encrypted, 24*time.Hour)
108-
109-
return nil
110-
}

internal/oauth/auth.go renamed to internal/auth/google.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package oauth
1+
package auth
22

33
import (
44
"context"
@@ -58,21 +58,21 @@ func newAuthService(config Config) (*authService, error) {
5858
}, nil
5959
}
6060

61-
// googleAuthURL returns the Google OAuth authorization URL
62-
func (s *authService) googleAuthURL(state string) string {
61+
// GoogleAuthURL returns the Google OAuth authorization URL
62+
func (s *authService) GoogleAuthURL(state string) string {
6363
return s.googleOAuth.AuthCodeURL(state,
6464
oauth2.AccessTypeOffline,
6565
oauth2.ApprovalForce,
6666
)
6767
}
6868

69-
// exchangeCodeForToken exchanges the authorization code for a token
70-
func (s *authService) exchangeCodeForToken(ctx context.Context, code string) (*oauth2.Token, error) {
69+
// ExchangeCodeForToken exchanges the authorization code for a token
70+
func (s *authService) ExchangeCodeForToken(ctx context.Context, code string) (*oauth2.Token, error) {
7171
return s.googleOAuth.Exchange(ctx, code)
7272
}
7373

74-
// validateUser validates the Google OAuth token and checks domain membership
75-
func (s *authService) validateUser(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
74+
// ValidateUser validates the Google OAuth token and checks domain membership
75+
func (s *authService) ValidateUser(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
7676
client := s.googleOAuth.Client(ctx, token)
7777
userInfoURL := "https://www.googleapis.com/oauth2/v2/userinfo"
7878
if customURL := os.Getenv("GOOGLE_USERINFO_URL"); customURL != "" {
@@ -115,8 +115,8 @@ func (s *authService) validateUser(ctx context.Context, token *oauth2.Token) (*U
115115
return &userInfo, nil
116116
}
117117

118-
// parseClientRequest parses and validates a client registration request
119-
func (s *authService) parseClientRequest(metadata map[string]interface{}) ([]string, []string, error) {
118+
// ParseClientRequest parses and validates a client registration request
119+
func (s *authService) ParseClientRequest(metadata map[string]interface{}) ([]string, []string, error) {
120120
// Extract redirect URIs
121121
redirectURIs := []string{}
122122
if uris, ok := metadata["redirect_uris"].([]interface{}); ok {

internal/auth/server.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"github.com/dgellow/mcp-front/internal"
13+
"github.com/dgellow/mcp-front/internal/crypto"
14+
"github.com/dgellow/mcp-front/internal/log"
15+
"github.com/dgellow/mcp-front/internal/storage"
16+
"github.com/ory/fosite"
17+
"github.com/ory/fosite/compose"
18+
)
19+
20+
// userContextKey is the context key for user email
21+
const userContextKey contextKey = "user_email"
22+
23+
// GetUserFromContext extracts user email from context
24+
func GetUserFromContext(ctx context.Context) (string, bool) {
25+
email, ok := ctx.Value(userContextKey).(string)
26+
return email, ok
27+
}
28+
29+
// GetUserContextKey returns the context key for user email (for testing)
30+
func GetUserContextKey() contextKey {
31+
return userContextKey
32+
}
33+
34+
// Server wraps fosite.OAuth2Provider with clean architecture
35+
type Server struct {
36+
provider fosite.OAuth2Provider
37+
storage storage.Storage
38+
authService *authService
39+
config Config
40+
sessionEncryptor crypto.Encryptor // Created once for browser SSO performance
41+
}
42+
43+
// Config holds OAuth server configuration
44+
type Config struct {
45+
Issuer string
46+
TokenTTL time.Duration
47+
SessionDuration time.Duration // Duration for browser session cookies (default: 24h)
48+
AllowedDomains []string
49+
AllowedOrigins []string // For CORS validation
50+
GoogleClientID string
51+
GoogleClientSecret string
52+
GoogleRedirectURI string
53+
JWTSecret string // Should be provided via environment variable
54+
EncryptionKey string // Should be provided via environment variable
55+
StorageType string // "memory" or "firestore"
56+
GCPProjectID string // Required for firestore storage
57+
FirestoreDatabase string // Optional: Firestore database name (default: "(default)")
58+
FirestoreCollection string // Optional: Collection name for Firestore storage (default: "mcp_front_oauth_clients")
59+
}
60+
61+
// NewServer creates a new OAuth 2.1 server
62+
func NewServer(config Config, store storage.Storage) (*Server, error) {
63+
// Create session encryptor for browser SSO
64+
key := []byte(string(config.EncryptionKey))
65+
sessionEncryptor, err := crypto.NewEncryptor(key)
66+
if err != nil {
67+
return nil, fmt.Errorf("failed to create session encryptor: %w", err)
68+
}
69+
log.Logf("Session encryptor initialized for browser SSO")
70+
71+
// Create auth service (business logic)
72+
authService, err := newAuthService(config)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to create auth service: %w", err)
75+
}
76+
77+
// Use provided JWT secret or generate a secure one
78+
var secret []byte
79+
if config.JWTSecret != "" {
80+
secret = []byte(string(config.JWTSecret))
81+
// Validate JWT secret length for HMAC-SHA512/256
82+
if len(secret) < 32 {
83+
return nil, fmt.Errorf("JWT secret must be at least 32 bytes long for security, got %d bytes", len(secret))
84+
}
85+
} else {
86+
secret = make([]byte, 32)
87+
if _, err := rand.Read(secret); err != nil {
88+
return nil, fmt.Errorf("failed to generate JWT secret: %w", err)
89+
}
90+
log.LogWarn("Generated random JWT secret. Set JWT_SECRET env var for persistent tokens across restarts")
91+
}
92+
93+
// Determine min parameter entropy based on environment
94+
minEntropy := 8 // Production default - enforce secure state parameters (8+ chars)
95+
log.Logf("OAuth server initialization - MCP_FRONT_ENV=%s, isDevelopmentMode=%v", os.Getenv("MCP_FRONT_ENV"), internal.IsDevelopmentMode())
96+
if internal.IsDevelopmentMode() {
97+
minEntropy = 0 // Development mode - allow empty state parameters
98+
log.LogWarn("Development mode enabled - OAuth security checks relaxed (state parameter entropy: %d)", minEntropy)
99+
}
100+
101+
// Configure fosite
102+
oauthConfig := &compose.Config{
103+
AccessTokenLifespan: config.TokenTTL,
104+
RefreshTokenLifespan: config.TokenTTL * 2,
105+
AuthorizeCodeLifespan: 10 * time.Minute,
106+
MinParameterEntropy: minEntropy,
107+
EnforcePKCE: true,
108+
ScopeStrategy: fosite.HierarchicScopeStrategy,
109+
AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy,
110+
HashCost: 12,
111+
}
112+
113+
// Create provider using compose
114+
provider := compose.ComposeAllEnabled(
115+
oauthConfig,
116+
store,
117+
secret,
118+
nil, // RSA key not needed for our use case
119+
)
120+
121+
return &Server{
122+
provider: provider,
123+
storage: store,
124+
authService: authService,
125+
config: config,
126+
sessionEncryptor: sessionEncryptor,
127+
}, nil
128+
}
129+
130+
// GetProvider returns the fosite OAuth2Provider
131+
func (s *Server) GetProvider() fosite.OAuth2Provider {
132+
return s.provider
133+
}
134+
135+
// GetStorage returns the storage instance
136+
func (s *Server) GetStorage() storage.Storage {
137+
return s.storage
138+
}
139+
140+
// GetAuthService returns the auth service
141+
func (s *Server) GetAuthService() *authService {
142+
return s.authService
143+
}
144+
145+
// GetConfig returns the server configuration
146+
func (s *Server) GetConfig() Config {
147+
return s.config
148+
}
149+
150+
// GetSessionEncryptor returns the session encryptor
151+
func (s *Server) GetSessionEncryptor() crypto.Encryptor {
152+
return s.sessionEncryptor
153+
}
154+
155+
// ValidateTokenMiddleware creates middleware that validates OAuth tokens
156+
func (s *Server) ValidateTokenMiddleware() func(http.Handler) http.Handler {
157+
return func(next http.Handler) http.Handler {
158+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
159+
ctx := r.Context()
160+
161+
// Extract token from Authorization header
162+
auth := r.Header.Get("Authorization")
163+
if auth == "" {
164+
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
165+
return
166+
}
167+
168+
parts := strings.Split(auth, " ")
169+
if len(parts) != 2 || parts[0] != "Bearer" {
170+
http.Error(w, "Invalid authorization header format", http.StatusUnauthorized)
171+
return
172+
}
173+
174+
token := parts[1]
175+
176+
// Validate token and extract session
177+
// IMPORTANT: Fosite's IntrospectToken behavior is non-intuitive:
178+
// - The session parameter passed to IntrospectToken is NOT populated with data
179+
// - This is documented fosite behavior, not a bug
180+
// - The actual session data must be retrieved from the returned AccessRequester
181+
// See: https://github.com/ory/fosite/issues/256
182+
session := &Session{DefaultSession: &fosite.DefaultSession{}}
183+
_, accessRequest, err := s.provider.IntrospectToken(ctx, token, fosite.AccessToken, session)
184+
if err != nil {
185+
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
186+
return
187+
}
188+
189+
// Get the actual session from the access request (not the input session parameter)
190+
// This is the correct way to retrieve session data after token introspection
191+
var userEmail string
192+
if accessRequest != nil {
193+
if reqSession, ok := accessRequest.GetSession().(*Session); ok {
194+
if reqSession.UserInfo != nil && reqSession.UserInfo.Email != "" {
195+
userEmail = reqSession.UserInfo.Email
196+
}
197+
}
198+
}
199+
200+
// Pass user info through context
201+
if userEmail != "" {
202+
ctx = context.WithValue(ctx, userContextKey, userEmail)
203+
r = r.WithContext(ctx)
204+
}
205+
206+
next.ServeHTTP(w, r)
207+
})
208+
}
209+
}

internal/oauth/session.go renamed to internal/auth/session.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package oauth
1+
package auth
22

33
import (
44
"time"

0 commit comments

Comments
 (0)