diff --git a/internal/auth/header_validator.go b/internal/auth/header_validator.go index 70aa945..8c7dd9a 100644 --- a/internal/auth/header_validator.go +++ b/internal/auth/header_validator.go @@ -49,39 +49,46 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali return nil, errors.NewAuthenticationError("User authentication required") } - if timestamp == "" || signature == "" { - hv.logger.Warn("Missing authentication signature headers", + // In development mode, skip signature validation for trusted headers + if hv.config.IsDevelopment() { + hv.logger.Debug("Development mode: skipping signature validation", zap.String("user_email", userEmail)) - return nil, errors.NewAuthenticationError("Authentication signature required") - } + } else { + // Production mode: require full signature validation + if timestamp == "" || signature == "" { + hv.logger.Warn("Missing authentication signature headers", + zap.String("user_email", userEmail)) + return nil, errors.NewAuthenticationError("Authentication signature required") + } - // Validate timestamp (prevent replay attacks) - timestampInt, err := strconv.ParseInt(timestamp, 10, 64) - if err != nil { - hv.logger.Warn("Invalid timestamp format", - zap.String("timestamp", timestamp), - zap.String("user_email", userEmail)) - return nil, errors.NewAuthenticationError("Invalid timestamp format") - } + // Validate timestamp (prevent replay attacks) + timestampInt, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + hv.logger.Warn("Invalid timestamp format", + zap.String("timestamp", timestamp), + zap.String("user_email", userEmail)) + return nil, errors.NewAuthenticationError("Invalid timestamp format") + } - timestampTime := time.Unix(timestampInt, 0) - now := time.Now() - - // Allow 5 minutes clock skew - maxAge := 5 * time.Minute - if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) { - hv.logger.Warn("Timestamp outside acceptable window", - zap.Time("timestamp", timestampTime), - zap.Time("now", now), - zap.String("user_email", userEmail)) - return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window") - } + timestampTime := time.Unix(timestampInt, 0) + now := time.Now() + + // Allow 5 minutes clock skew + maxAge := 5 * time.Minute + if now.Sub(timestampTime) > maxAge || timestampTime.After(now.Add(1*time.Minute)) { + hv.logger.Warn("Timestamp outside acceptable window", + zap.Time("timestamp", timestampTime), + zap.Time("now", now), + zap.String("user_email", userEmail)) + return nil, errors.NewAuthenticationError("Request timestamp outside acceptable window") + } - // Validate HMAC signature - if !hv.validateSignature(userEmail, timestamp, signature) { - hv.logger.Warn("Invalid authentication signature", - zap.String("user_email", userEmail)) - return nil, errors.NewAuthenticationError("Invalid authentication signature") + // Validate HMAC signature + if !hv.validateSignature(userEmail, timestamp, signature) { + hv.logger.Warn("Invalid authentication signature", + zap.String("user_email", userEmail)) + return nil, errors.NewAuthenticationError("Invalid authentication signature") + } } // Validate email format @@ -94,11 +101,24 @@ func (hv *HeaderValidator) ValidateAuthenticationHeaders(r *http.Request) (*Vali hv.logger.Debug("Authentication headers validated successfully", zap.String("user_email", userEmail)) + // Set defaults for development mode + var timestampTime time.Time + var signatureValue string + + if hv.config.IsDevelopment() { + timestampTime = time.Now() + signatureValue = "dev-mode-bypass" + } else { + timestampInt, _ := strconv.ParseInt(timestamp, 10, 64) + timestampTime = time.Unix(timestampInt, 0) + signatureValue = signature + } + return &ValidatedUserContext{ UserID: userEmail, Email: userEmail, Timestamp: timestampTime, - Signature: signature, + Signature: signatureValue, }, nil } diff --git a/internal/domain/models.go b/internal/domain/models.go index dfa42f2..76cfb5a 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -124,11 +124,20 @@ type VerifyResponse struct { Error string `json:"error,omitempty"` } +// TokenDeliveryMode specifies how tokens should be delivered in redirect flows +type TokenDeliveryMode string + +const ( + TokenDeliveryCookie TokenDeliveryMode = "cookie" // Token in secure cookie (default) + TokenDeliveryQuery TokenDeliveryMode = "query" // Token in query parameter (for integrations) +) + // LoginRequest represents a user login request type LoginRequest struct { - AppID string `json:"app_id" validate:"required"` - Permissions []string `json:"permissions,omitempty"` - RedirectURI string `json:"redirect_uri,omitempty"` + AppID string `json:"app_id" validate:"required"` + Permissions []string `json:"permissions,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + TokenDelivery TokenDeliveryMode `json:"token_delivery,omitempty"` // How to deliver token in redirect flows } // LoginResponse represents a user login response diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index f3846ca..483d495 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -81,25 +81,53 @@ func (h *AuthHandler) Login(c *gin.Context) { return } - // For redirect flows, use secure cookie-based token delivery - // Set secure cookie with the token - c.SetSameSite(http.SameSiteStrictMode) - c.SetCookie( - "auth_token", // name - token, // value - 604800, // maxAge (7 days) - "/", // path - "", // domain (empty for current domain) - true, // secure (HTTPS only) - true, // httpOnly (no JavaScript access) - ) + // For redirect flows, choose token delivery method + // Default to cookie delivery for security + tokenDelivery := req.TokenDelivery + if tokenDelivery == "" { + tokenDelivery = domain.TokenDeliveryCookie + } + + h.logger.Debug("Token delivery mode", zap.String("mode", string(tokenDelivery))) // Generate a secure state parameter for CSRF protection state := h.generateSecureState(userContext.UserID, req.AppID) - - // Redirect without token in URL + var redirectURL string + + switch tokenDelivery { + case domain.TokenDeliveryQuery: + // Deliver token via query parameter (for integrations like VS Code) + redirectURL = req.RedirectURI + "?token=" + token + "&state=" + state + + case domain.TokenDeliveryCookie: + // Deliver token via secure cookie (default, more secure) + c.SetSameSite(http.SameSiteStrictMode) + + // In development mode, make cookie accessible to JavaScript for testing + // In production, keep HTTP-only for security + httpOnly := !h.config.IsDevelopment() + secure := !h.config.IsDevelopment() // Only require HTTPS in production + + c.SetCookie( + "auth_token", // name + token, // value + 604800, // maxAge (7 days) + "/", // path + "", // domain (empty for current domain) + secure, // secure (HTTPS only in production) + httpOnly, // httpOnly (no JavaScript access in production) + ) + + // Redirect without token in URL for security + redirectURL = req.RedirectURI + "?state=" + state + + default: + // Invalid delivery mode, default to cookie + redirectURL = req.RedirectURI + "?state=" + state + } + response := domain.LoginResponse{ - RedirectURL: req.RedirectURI + "?state=" + state, + RedirectURL: redirectURL, } c.JSON(http.StatusOK, response) diff --git a/kms-frontend/src/components/TokenTester.tsx b/kms-frontend/src/components/TokenTester.tsx index 52e51b5..a989607 100644 --- a/kms-frontend/src/components/TokenTester.tsx +++ b/kms-frontend/src/components/TokenTester.tsx @@ -47,7 +47,13 @@ interface CallbackTestResult { success: boolean; token?: string; verified?: boolean; + permitted?: boolean; + user_id?: string; permissions?: string[]; + permission_results?: Record; + expires_at?: string; + max_valid_at?: string; + token_type?: string; error?: string; timestamp: string; } @@ -79,6 +85,8 @@ const TokenTester: React.FC = () => { const [callbackModalVisible, setCallbackModalVisible] = useState(false); const [form] = Form.useForm(); const [callbackForm] = Form.useForm(); + const [useCallback, setUseCallback] = useState(false); + const [extractedToken, setExtractedToken] = useState(''); useEffect(() => { loadApplications(); @@ -123,7 +131,8 @@ const TokenTester: React.FC = () => { const response = await apiService.login( values.app_id, values.permissions || [], - values.use_callback ? callbackUrl : undefined + values.use_callback ? callbackUrl : undefined, + values.token_delivery || 'query' ); console.log('Login response:', response); @@ -146,16 +155,25 @@ const TokenTester: React.FC = () => { // If we have a redirect URL, show the callback modal if (response.redirect_url && values.use_callback) { setCallbackModalVisible(true); - // Pre-fill the callback form with the token from the redirect URL - const urlParams = new URLSearchParams(response.redirect_url.split('?')[1]); - const token = urlParams.get('token'); - if (token) { - callbackForm.setFieldsValue({ - app_id: values.app_id, - token: token, - permissions: values.permissions || [], - }); + + // Extract token from redirect URL if using query parameter delivery + let tokenFromUrl = ''; + if (values.token_delivery === 'query') { + try { + const url = new URL(response.redirect_url); + tokenFromUrl = url.searchParams.get('token') || ''; + } catch (e) { + console.warn('Failed to parse redirect URL for token extraction:', e); + } } + + setExtractedToken(tokenFromUrl); + + callbackForm.setFieldsValue({ + app_id: values.app_id, + token: tokenFromUrl, // Pre-fill with extracted token if available + permissions: values.permissions || [], + }); } } catch (error: any) { @@ -195,7 +213,13 @@ const TokenTester: React.FC = () => { success: verifyResponse.valid, token: values.token, verified: verifyResponse.valid, + permitted: verifyResponse.permitted, + user_id: verifyResponse.user_id, permissions: verifyResponse.permissions, + permission_results: verifyResponse.permission_results, + expires_at: verifyResponse.expires_at, + max_valid_at: verifyResponse.max_valid_at, + token_type: verifyResponse.token_type, error: verifyResponse.error, timestamp: new Date().toISOString(), }; @@ -205,8 +229,16 @@ const TokenTester: React.FC = () => { if (verifyResponse.valid) { message.success('Callback test completed successfully!'); + // Auto-close modal after successful verification to show results + setTimeout(() => { + setCallbackModalVisible(false); + }, 1500); } else { message.warning('Callback test completed - token verification failed'); + // Auto-close modal after failed verification to show results + setTimeout(() => { + setCallbackModalVisible(false); + }, 1500); } } catch (error: any) { @@ -221,6 +253,11 @@ const TokenTester: React.FC = () => { setCallbackResult(result); setCurrentStep(4); message.error('Callback test failed'); + + // Auto-close modal to show error results + setTimeout(() => { + setCallbackModalVisible(false); + }, 1500); } finally { setCallbackLoading(false); } @@ -231,6 +268,8 @@ const TokenTester: React.FC = () => { setLoginResult(null); setCallbackResult(null); setCallbackModalVisible(false); + setUseCallback(false); + setExtractedToken(''); form.resetFields(); callbackForm.resetFields(); // Clear stored test data @@ -257,6 +296,20 @@ const TokenTester: React.FC = () => { Test the /login flow and callback handling for user tokens +
+ + Direct Mode: Login returns token directly in response body (no callback)
+ Callback Mode: Login returns redirect URL, token in query parameter (default) or secure cookie +
+ } + type="info" + showIcon + style={{ fontSize: '12px' }} + /> +