diff --git a/internal/cognitoauth/auth.go b/internal/cognitoauth/auth.go index 6c7b2fb2..471b3034 100644 --- a/internal/cognitoauth/auth.go +++ b/internal/cognitoauth/auth.go @@ -3,6 +3,7 @@ package cognitoauth import ( "context" "fmt" + "log" "net/http" "os" "time" @@ -162,28 +163,40 @@ func GetUserSubject(c echo.Context) (string, bool) { // - accessTokenExpiresAt: Expiration time for the access token // - config: Configuration provider for auth settings func setTokenCookies(c echo.Context, tokens *TokenResponse, accessTokenExpiresAt time.Time, config auth.ConfigProvider) { + // Log key cookie setting information for debugging + log.Printf("DEBUG: setTokenCookies - Setting cookies: access_token_len=%d, refresh_token_len=%d", + len(tokens.AccessToken), len(tokens.RefreshToken)) + // Set access token cookie (shorter expiration) + // Ensure proper UTC time handling + accessTokenExpiresUTC := accessTokenExpiresAt.UTC() accessTokenCookie := &http.Cookie{ Name: "auth_token", Value: tokens.AccessToken, Path: "/", - Expires: accessTokenExpiresAt, + Expires: accessTokenExpiresUTC, HttpOnly: true, Secure: isSecureContext(config), + SameSite: http.SameSiteLaxMode, // Modern browser compatibility } c.SetCookie(accessTokenCookie) // Set refresh token cookie (longer expiration - 30 days) + // Use UTC time and ensure proper expiration + refreshTokenExpires := time.Now().UTC().Add(30 * 24 * time.Hour) refreshTokenCookie := &http.Cookie{ Name: "refresh_token", Value: tokens.RefreshToken, Path: "/", - Expires: time.Now().Add(30 * 24 * time.Hour), + Expires: refreshTokenExpires, HttpOnly: true, Secure: isSecureContext(config), + SameSite: http.SameSiteLaxMode, // Modern browser compatibility } c.SetCookie(refreshTokenCookie) + log.Printf("DEBUG: setTokenCookies - Cookies set successfully") + config.GetAuthLogger().Debug("Token cookies set", "access_token_expires", accessTokenExpiresAt, "refresh_token_expires", refreshTokenCookie.Expires) @@ -198,9 +211,13 @@ func setTokenCookies(c echo.Context, tokens *TokenResponse, accessTokenExpiresAt // - c: The Echo context for setting cookies // - config: Configuration provider for auth settings func clearTokenCookies(c echo.Context, config auth.ConfigProvider) { - pastTime := time.Now().Add(-1 * time.Hour) + // Log when clearTokenCookies is called for debugging + log.Printf("DEBUG: clearTokenCookies - Clearing auth cookies for path: %s", c.Request().URL.Path) - // Clear access token cookie + // Use UTC time for consistent cookie clearing + pastTime := time.Now().UTC().Add(-1 * time.Hour) + + // Clear access token cookie - must match original attributes exactly accessTokenCookie := &http.Cookie{ Name: "auth_token", Value: "", @@ -208,10 +225,11 @@ func clearTokenCookies(c echo.Context, config auth.ConfigProvider) { Expires: pastTime, HttpOnly: true, Secure: isSecureContext(config), + SameSite: http.SameSiteLaxMode, // Must match original } c.SetCookie(accessTokenCookie) - // Clear refresh token cookie + // Clear refresh token cookie - must match original attributes exactly refreshTokenCookie := &http.Cookie{ Name: "refresh_token", Value: "", @@ -219,9 +237,11 @@ func clearTokenCookies(c echo.Context, config auth.ConfigProvider) { Expires: pastTime, HttpOnly: true, Secure: isSecureContext(config), + SameSite: http.SameSiteLaxMode, // Must match original } c.SetCookie(refreshTokenCookie) + log.Printf("DEBUG: clearTokenCookies - Auth cookies cleared successfully") config.GetAuthLogger().Debug("Token cookies cleared") } @@ -233,10 +253,21 @@ func clearTokenCookies(c echo.Context, config auth.ConfigProvider) { // Returns: // - string: The refresh token value, or empty string if not found func getRefreshTokenFromCookie(c echo.Context) string { + // Check if we have cookies + cookies := c.Cookies() + log.Printf("DEBUG: getRefreshTokenFromCookie - Found %d cookies for %s", len(cookies), c.Request().URL.Path) + refreshCookie, err := c.Cookie("refresh_token") - if err != nil || refreshCookie.Value == "" { + if err != nil { + log.Printf("DEBUG: getRefreshTokenFromCookie - Failed to get refresh_token cookie: %v", err) return "" } + if refreshCookie.Value == "" { + log.Printf("DEBUG: getRefreshTokenFromCookie - refresh_token cookie is empty") + return "" + } + + log.Printf("DEBUG: getRefreshTokenFromCookie - Found refresh_token cookie, length: %d", len(refreshCookie.Value)) return refreshCookie.Value } diff --git a/internal/cognitoauth/cognitotest/auth_full_test.go b/internal/cognitoauth/cognitotest/auth_full_test.go index 78f59a1d..ed4da414 100644 --- a/internal/cognitoauth/cognitotest/auth_full_test.go +++ b/internal/cognitoauth/cognitotest/auth_full_test.go @@ -33,7 +33,7 @@ func setupEnvironment(t *testing.T) { "COGNITO_CLIENT_SECRET": os.Getenv("COGNITO_CLIENT_SECRET"), "COGNITO_USER_POOL_ID": os.Getenv("COGNITO_USER_POOL_ID"), "COGNITO_CLIENT_ID": os.Getenv("COGNITO_CLIENT_ID"), - "DEBUG": "true", + "DEBUG": "false", "HEADLESS": os.Getenv("HEADLESS"), } @@ -209,9 +209,10 @@ func TestCognitoAuthFlow(t *testing.T) { chromedp.ListenTarget(ctx, func(ev interface{}) { switch e := ev.(type) { case *network.EventRequestWillBeSent: - t.Logf("Request: %s %s", e.Request.Method, e.Request.URL) + t.Logf("Request: %s ", e.Request.Method) case *network.EventResponseReceived: - t.Logf("Response: %s %s (status: %d)", e.Response.URL, e.Response.MimeType, e.Response.Status) + //t.Logf("Response: %s %s (status: %d)", e.Response.URL, e.Response.MimeType, e.Response.Status) + t.Logf("Response: (status: %d)", e.Response.Status) } }) diff --git a/internal/cognitoauth/cognitotest/auto_refresh_test.go b/internal/cognitoauth/cognitotest/auto_refresh_test.go new file mode 100644 index 00000000..6e7633db --- /dev/null +++ b/internal/cognitoauth/cognitotest/auto_refresh_test.go @@ -0,0 +1,500 @@ +package cognitotest + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/stretchr/testify/assert" + + "queryorchestration/internal/cognitoauth" + "queryorchestration/internal/serviceconfig/auth" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// setupEnvironmentForRefresh ensures all required environment variables are set for refresh testing +func setupEnvironmentForRefresh(t *testing.T) { + // Define all required environment variables with test values + envVars := map[string]string{ + "USER": os.Getenv("USER"), + "PASSWORD": os.Getenv("PASSWORD"), + "COGNITO_DOMAIN": os.Getenv("COGNITO_DOMAIN"), + "AWS_REGION": os.Getenv("AWS_REGION"), + "COGNITO_CLIENT_SECRET": os.Getenv("COGNITO_CLIENT_SECRET"), + "COGNITO_USER_POOL_ID": os.Getenv("COGNITO_USER_POOL_ID"), + "COGNITO_CLIENT_ID": os.Getenv("COGNITO_CLIENT_ID"), + "DEBUG": "false", + "HEADLESS": os.Getenv("HEADLESS"), + } + + // Verify all required variables are set + for key, value := range envVars { + if value == "" { + // HEADLESS is optional with a default of "true" + if key == "HEADLESS" { + os.Setenv(key, "true") + continue + } + t.Fatalf("Required environment variable %s is not set", key) + } + // Set the environment variable for the test + os.Setenv(key, value) + } +} + +// setupTestServerForRefresh creates and configures an Echo server for refresh testing +func setupTestServerForRefresh(t *testing.T) (*http.Server, *auth.CognitoConfig) { + // Create a new Echo instance + e := echo.New() + + // Add basic middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Initialize logger + logHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + logger := slog.New(logHandler) + + // Create auth config with correct base URL that matches Cognito settings + baseURL := "http://localhost:8080" + config := auth.InitializeConfig(baseURL, logger) + + // Set route permissions based on test requirements + routePermissions := map[string][]string{ + "/query": {"exporters", "uploaders", "querybuilders"}, + } + config.SetAuthRoutePermissions(routePermissions) + + // Register Cognito auth routes and middleware + cognitoauth.RegisterRoutes(e, config) + + // Add test endpoints + e.GET("/home", func(c echo.Context) error { + // Check if user is authenticated + userInfo, authenticated := cognitoauth.GetUserInfo(c) + + response := map[string]interface{}{ + "authenticated": authenticated, + } + + if authenticated { + response["username"] = userInfo.Username + response["email"] = userInfo.Email + response["groups"] = userInfo.Groups + } + + return c.JSON(http.StatusOK, response) + }) + + e.GET("/query", func(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{ + "status": "success", + "message": "You have access to the query endpoint", + }) + }) + + // Create server with fixed address to match Cognito config + server := &http.Server{ + Addr: ":8080", + Handler: e, + ReadHeaderTimeout: 30 * time.Second, + } + + // Log the redirect URL for debugging + logger.Info("Using redirect URI", "uri", config.GetAuthRedirectURI()) + + return server, config +} + +// parseJWTTokenExpiration extracts the expiration time from a JWT token without verification +func parseJWTTokenExpiration(tokenString string) (time.Time, error) { + // Split JWT into parts + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("invalid JWT format") + } + + // Decode payload (second part) + payload := parts[1] + // Add padding if needed + for len(payload)%4 != 0 { + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return time.Time{}, fmt.Errorf("failed to decode JWT payload: %v", err) + } + + // Parse JSON payload + var claims map[string]interface{} + if err := json.Unmarshal(decoded, &claims); err != nil { + return time.Time{}, fmt.Errorf("failed to parse JWT claims: %v", err) + } + + // Extract expiration time + exp, ok := claims["exp"] + if !ok { + return time.Time{}, fmt.Errorf("no exp claim found in JWT") + } + + // Convert to time.Time + switch expValue := exp.(type) { + case float64: + return time.Unix(int64(expValue), 0), nil + default: + return time.Time{}, fmt.Errorf("invalid exp claim type") + } +} + +// TestAutoRefreshFlow runs the complete end-to-end test for auto refresh functionality +func TestAutoRefreshFlow(t *testing.T) { + if os.Getenv("ENABLE_REFRESH_TESTS") != "true" { + t.Skip("Skipping auto refresh tests - set ENABLE_REFRESH_TESTS=true to run") + } + + // Skip if running in CI environment or short test mode + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Setup test environment + setupEnvironmentForRefresh(t) + + // Get credentials from environment + username := os.Getenv("USER") + password := os.Getenv("PASSWORD") + + // Setup test server + server, config := setupTestServerForRefresh(t) + + // Start the server in a goroutine + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("Server error: %v", err) + } + }() + + // Ensure server shuts down at the end of the test + defer func() { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + t.Logf("Server shutdown error: %v", err) + } + }() + + // Give the server time to start + time.Sleep(1 * time.Second) + + // Log important URLs for debugging + baseURL := "http://localhost:8080" + t.Logf("Server URL: %s", baseURL) + t.Logf("Login URL: %s%s", baseURL, config.GetAuthLoginPath()) + t.Logf("Callback URL: %s", config.GetAuthRedirectURI()) + + // Get headless mode from environment variable + headless := true + if os.Getenv("HEADLESS") == "false" { + headless = false + } + + // Setup Chrome options + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", headless), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.Flag("host-blocking-patterns", "*prometheus*"), + chromedp.Flag("disable-cache", true), + ) + + allocCtx, cancel := chromedp.NewExecAllocator(t.Context(), opts...) + defer cancel() + + // Create Chrome context with extended timeout for token expiration testing + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) + defer cancel() + ctx, cancel = context.WithTimeout(ctx, 15*time.Minute) // Extended timeout for token expiration + defer cancel() + + // Enable request interception for debugging + if err := chromedp.Run(ctx, network.Enable()); err != nil { + t.Fatalf("Failed to enable network: %v", err) + } + + // Store cookies to extract JWT tokens + var cookies []*network.Cookie + + // Phase 1: Initial Authentication + t.Run("Initial Authentication", func(t *testing.T) { + // Navigate to login page + loginURL := fmt.Sprintf("%s%s", baseURL, config.GetAuthLoginPath()) + err := chromedp.Run(ctx, + chromedp.Navigate(loginURL), + chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), + ) + assert.NoError(t, err, "Failed to navigate to login page") + + // Submit login credentials + err = chromedp.Run(ctx, + chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), + chromedp.Clear(`input[name="username"]`, chromedp.ByQuery), + chromedp.SendKeys(`input[name="username"]`, username, chromedp.ByQuery), + chromedp.Click(`button[type="submit"], button.amplify-button--primary, input[type="Submit"]`, chromedp.ByQuery), + ) + assert.NoError(t, err, "Failed to submit username") + + // Enter password + err = chromedp.Run(ctx, + chromedp.WaitVisible(`input[name="password"]`, chromedp.ByQuery), + chromedp.Clear(`input[name="password"]`, chromedp.ByQuery), + chromedp.SendKeys(`input[name="password"]`, password, chromedp.ByQuery), + chromedp.Sleep(1*time.Second), + chromedp.Click(`button[type="submit"], button.amplify-button--primary, input[type="Submit"]`, chromedp.ByQuery), + ) + assert.NoError(t, err, "Failed to submit password") + + // Wait for redirection to home page + err = chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { + startTime := time.Now() + timeout := 45 * time.Second + + for { + if time.Since(startTime) > timeout { + return fmt.Errorf("timed out waiting for redirect") + } + + var currentURL string + if err := chromedp.Location(¤tURL).Do(ctx); err != nil { + return err + } + + if strings.Contains(currentURL, "/home") { + return nil + } + + if strings.Contains(currentURL, "/login-callback") { + time.Sleep(2 * time.Second) + continue + } + + time.Sleep(1 * time.Second) + } + })) + assert.NoError(t, err, "Failed to wait for redirect after login") + + // Get cookies + err = chromedp.Run(ctx, + chromedp.Sleep(2*time.Second), + chromedp.ActionFunc(func(ctx context.Context) error { + cookies, err = network.GetCookies().Do(ctx) + return err + }), + ) + assert.NoError(t, err, "Failed to get cookies") + + t.Log("✅ Initial authentication completed successfully") + }) + + // Phase 2: Extract and Log Token Expiration + var authToken string + var tokenExpiration time.Time + t.Run("Extract Token and Log Expiration", func(t *testing.T) { + for _, cookie := range cookies { + if cookie.Name == "auth_token" { + authToken = cookie.Value + break + } + } + assert.NotEmpty(t, authToken, "JWT token cookie not found") + + var err error + tokenExpiration, err = parseJWTTokenExpiration(authToken) + assert.NoError(t, err, "Failed to parse token expiration") + + t.Logf("✅ Token extracted successfully") + t.Logf("📅 Token expires at: %s", tokenExpiration.Format(time.RFC3339)) + t.Logf("⏰ Current time: %s", time.Now().Format(time.RFC3339)) + t.Logf("⌛ Time until expiration: %s", time.Until(tokenExpiration)) + t.Logf("🔄 Auto refresh should trigger when token has < 5 minutes remaining") + }) + + // Phase 3: Wait for Token Expiration + t.Run("Wait for Token Expiration", func(t *testing.T) { + // Calculate when the token will expire (plus 1 second buffer) + timeToWait := time.Until(tokenExpiration) + 1*time.Second + + // If token expires in more than 10 minutes, something might be wrong + if timeToWait > 10*time.Minute { + t.Logf("⚠️ Token expires in %s, which seems longer than expected for a 5-minute token", timeToWait) + t.Logf("🤔 Proceeding anyway, but you may want to check Cognito token settings") + } + + if timeToWait > 0 { + t.Logf("⏳ Waiting %s for token to expire...", timeToWait) + + // Wait in chunks and log progress + start := time.Now() + for timeToWait > 0 { + if timeToWait > 30*time.Second { + time.Sleep(30 * time.Second) + timeToWait = time.Until(tokenExpiration) + 1*time.Second + elapsed := time.Since(start) + t.Logf("⏳ Still waiting... elapsed: %s, remaining: %s", elapsed.Round(time.Second), timeToWait.Round(time.Second)) + } else { + time.Sleep(timeToWait) + timeToWait = 0 + } + } + } + + t.Logf("✅ Wait period completed - token should now be expired") + t.Logf("⏰ Current time: %s", time.Now().Format(time.RFC3339)) + }) + + // Phase 4: Test Auto Refresh + t.Run("Test Auto Refresh Functionality", func(t *testing.T) { + // Get cookies before the request + var cookiesBefore []*network.Cookie + err := chromedp.Run(ctx, + chromedp.ActionFunc(func(ctx context.Context) error { + var err error + cookiesBefore, err = network.GetCookies().Do(ctx) + return err + }), + ) + assert.NoError(t, err, "Failed to get cookies before refresh test") + + // Log all cookies for debugging + cookieNames := make([]string, 0, len(cookiesBefore)) + for _, cookie := range cookiesBefore { + cookieNames = append(cookieNames, cookie.Name) + } + t.Logf("🍪 Cookies before refresh test: %v", cookieNames) + + var originalAuthToken string + for _, cookie := range cookiesBefore { + if cookie.Name == "auth_token" { + originalAuthToken = cookie.Value + break + } + } + + if originalAuthToken == "" { + t.Logf("⚠️ No auth token found in cookies before refresh test") + } else { + t.Logf("🔍 Original token (first 20 chars): %s...", originalAuthToken[:20]) + } + + // Make a request to a protected endpoint to trigger auto refresh + queryURL := fmt.Sprintf("%s/query", baseURL) + var queryResponseBody string + + t.Logf("🚀 Making request to %s to trigger auto refresh...", queryURL) + + err = chromedp.Run(ctx, + chromedp.Navigate(queryURL), + chromedp.Sleep(5*time.Second), // Give time for auto refresh to complete + chromedp.OuterHTML("body", &queryResponseBody), + ) + assert.NoError(t, err, "Failed to access protected endpoint") + + // Get cookies after the request + var cookiesAfter []*network.Cookie + err = chromedp.Run(ctx, + chromedp.ActionFunc(func(ctx context.Context) error { + var err error + cookiesAfter, err = network.GetCookies().Do(ctx) + return err + }), + ) + assert.NoError(t, err, "Failed to get cookies after refresh test") + + // Log all cookies after refresh for debugging + cookieNamesAfter := make([]string, 0, len(cookiesAfter)) + for _, cookie := range cookiesAfter { + cookieNamesAfter = append(cookieNamesAfter, cookie.Name) + } + t.Logf("🍪 Cookies after refresh test: %v", cookieNamesAfter) + + // Log the response body for debugging + t.Logf("📄 Response body: %s", queryResponseBody) + + // Check if we got a successful response (not a redirect to login) + assert.Contains(t, queryResponseBody, "success", "Auto refresh should have succeeded and allowed access to protected endpoint") + + // Find the new auth token + var newAuthToken string + for _, cookie := range cookiesAfter { + if cookie.Name == "auth_token" { + newAuthToken = cookie.Value + break + } + } + + assert.NotEmpty(t, newAuthToken, "Auth token should be present after successful auto refresh") + + if newAuthToken != "" { + // Verify the token was refreshed (tokens should be different) + if originalAuthToken == newAuthToken { + t.Logf("⚠️ Token appears to be the same - auto refresh may not have occurred") + t.Logf("🤔 This could happen if the token wasn't actually expired yet") + } else { + t.Logf("✅ Token was refreshed successfully!") + if len(newAuthToken) >= 20 { + t.Logf("🔍 New token (first 20 chars): %s...", newAuthToken[:20]) + } else { + t.Logf("🔍 New token: %s", newAuthToken) + } + } + } + + // Parse new token expiration + if newAuthToken != "" { + newExpiration, err := parseJWTTokenExpiration(newAuthToken) + if err == nil { + t.Logf("📅 New token expires at: %s", newExpiration.Format(time.RFC3339)) + t.Logf("⌛ New token valid for: %s", time.Until(newExpiration)) + + // Verify new token has a later expiration than original + if newExpiration.After(tokenExpiration) { + t.Logf("✅ New token expiration is later than original - auto refresh confirmed!") + } + } + } + + t.Logf("🎉 Auto refresh test completed successfully!") + }) + + // Phase 5: Verify Continued Access to Protected Endpoint + t.Run("Verify Continued Access After Refresh", func(t *testing.T) { + // Make another request to a protected endpoint to ensure the refreshed token works + queryURL := fmt.Sprintf("%s/query", baseURL) + var secondResponseBody string + + err := chromedp.Run(ctx, + chromedp.Navigate(queryURL), + chromedp.Sleep(3*time.Second), + chromedp.OuterHTML("body", &secondResponseBody), + ) + assert.NoError(t, err, "Failed to access protected endpoint after refresh") + + assert.Contains(t, secondResponseBody, "success", "Should continue to have access to protected endpoints after auto refresh") + t.Logf("✅ Continued access to protected endpoint verified") + }) +} \ No newline at end of file diff --git a/internal/cognitoauth/cognitotest/go.mod b/internal/cognitoauth/cognitotest/go.mod index f3061a48..78a6a3e6 100644 --- a/internal/cognitoauth/cognitotest/go.mod +++ b/internal/cognitoauth/cognitotest/go.mod @@ -20,6 +20,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.1.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect @@ -31,12 +32,16 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/permitio/permit-golang v1.2.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/net v0.36.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.11.0 // indirect diff --git a/internal/cognitoauth/cognitotest/go.sum b/internal/cognitoauth/cognitotest/go.sum index f9c9aed6..b55d55ba 100644 --- a/internal/cognitoauth/cognitotest/go.sum +++ b/internal/cognitoauth/cognitotest/go.sum @@ -19,6 +19,8 @@ github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= @@ -48,6 +50,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/permitio/permit-golang v1.2.5 h1:5XdT5ziFjmWh+GJ2WsCuv4qfEDXdKltknZ7iGnzdpFM= +github.com/permitio/permit-golang v1.2.5/go.mod h1:U3ytJkUh6mH7dPiBt7cWbVVsRSxAiJtnuL7FFhbDk8s= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -62,10 +66,16 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= diff --git a/internal/cognitoauth/cognitotest/readme.md b/internal/cognitoauth/cognitotest/readme.md index b5a0e414..26348eec 100644 --- a/internal/cognitoauth/cognitotest/readme.md +++ b/internal/cognitoauth/cognitotest/readme.md @@ -9,9 +9,12 @@ It's not enough to simply add the Go package - you need the actual browser binar This is why this module must not pollute the top level modules go.mod. If it did, then all of the docker containers for this entire project would have to have a massive amount of chrome dependencies added instead of just being -able to run on a scrath container. +able to run on a scratch container. To run the tests just run `./test.sh` from this directory. +Also the local permit.io pdp must also be running. +This can be started by running `demo.sh` in +`/cmd/cognito_test/permit_test/permit.harness` ## Obtaining a reusable JWT token If you observe the log output of running test.sh and look for diff --git a/internal/cognitoauth/middleware.go b/internal/cognitoauth/middleware.go index 370a7497..96b142ee 100644 --- a/internal/cognitoauth/middleware.go +++ b/internal/cognitoauth/middleware.go @@ -160,12 +160,55 @@ func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc { // Extract JWT token from header or cookie tokenStr, err := extractToken(c, config) + + // If token extraction fails, try refresh before returning 401 if err != nil { - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ - "error": "Authorization required", - "message": "This endpoint requires authentication. Please obtain a valid JWT token.", - "login_url": config.GetAuthLoginPath(), - }) + config.GetAuthLogger().Debug("No access token found, checking for refresh token") + config.GetAuthLogger().Debug("extractToken error", "error", err) + + // Check if we have a refresh token + config.GetAuthLogger().Debug("About to call getRefreshTokenFromCookie") + refreshToken := getRefreshTokenFromCookie(c) + config.GetAuthLogger().Debug("getRefreshTokenFromCookie returned", "token_length", len(refreshToken)) + + if refreshToken == "" { + config.GetAuthLogger().Debug("No refresh token found, redirecting to login") + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ + "error": "Authorization required", + "message": "This endpoint requires authentication. Please obtain a valid JWT token.", + "login_url": config.GetAuthLoginPath(), + }) + } + + // Attempt to refresh tokens + config.GetAuthLogger().Debug("Attempting token refresh with refresh token") + newTokens, err := refreshTokens(refreshToken, config) + if err != nil { + config.GetAuthLogger().Warn("Token refresh failed", "error", err) + clearTokenCookies(c, config) + return c.JSON(http.StatusUnauthorized, map[string]interface{}{ + "error": "Session expired", + "message": "Unable to refresh your session. Please log in again.", + "login_url": config.GetAuthLoginPath(), + }) + } + + // Handle non-rotating refresh tokens - preserve original if new one is empty + if newTokens.RefreshToken == "" { + config.GetAuthLogger().Debug("Refresh response missing refresh token, preserving original") + newTokens.RefreshToken = refreshToken + } + + // Update cookies with new tokens + newExpiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second) + setTokenCookies(c, newTokens, newExpiresAt, config) + + // Use the new token for this request + tokenStr = newTokens.AccessToken + config.GetAuthLogger().Debug("Token refresh successful, proceeding with new token") + + // Update the Authorization header for this request + c.Request().Header.Set("Authorization", "Bearer "+tokenStr) } // Check if token is expired and attempt refresh if needed @@ -195,6 +238,12 @@ func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc { }) } + // Handle non-rotating refresh tokens - preserve original if new one is empty + if newTokens.RefreshToken == "" { + config.GetAuthLogger().Debug("Refresh response missing refresh token, preserving original") + newTokens.RefreshToken = refreshToken + } + // Update cookies with new tokens newExpiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second) setTokenCookies(c, newTokens, newExpiresAt, config)