diff --git a/internal/cognitoauth/auth.go b/internal/cognitoauth/auth.go
index 8ae04295..6c7b2fb2 100644
--- a/internal/cognitoauth/auth.go
+++ b/internal/cognitoauth/auth.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"os"
+ "time"
"queryorchestration/internal/serviceconfig/auth"
@@ -148,3 +149,109 @@ func GetUserSubject(c echo.Context) (string, bool) {
return subject, true
}
+
+// setTokenCookies sets both access and refresh token cookies.
+//
+// This function creates HTTP-only cookies for both the access token and refresh token.
+// The access token cookie expires with the token, while the refresh token cookie
+// has a longer expiration (30 days) to allow for token refresh operations.
+//
+// Parameters:
+// - c: The Echo context for setting cookies
+// - tokens: TokenResponse containing both access and refresh tokens
+// - 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) {
+ // Set access token cookie (shorter expiration)
+ accessTokenCookie := &http.Cookie{
+ Name: "auth_token",
+ Value: tokens.AccessToken,
+ Path: "/",
+ Expires: accessTokenExpiresAt,
+ HttpOnly: true,
+ Secure: isSecureContext(config),
+ }
+ c.SetCookie(accessTokenCookie)
+
+ // Set refresh token cookie (longer expiration - 30 days)
+ refreshTokenCookie := &http.Cookie{
+ Name: "refresh_token",
+ Value: tokens.RefreshToken,
+ Path: "/",
+ Expires: time.Now().Add(30 * 24 * time.Hour),
+ HttpOnly: true,
+ Secure: isSecureContext(config),
+ }
+ c.SetCookie(refreshTokenCookie)
+
+ config.GetAuthLogger().Debug("Token cookies set",
+ "access_token_expires", accessTokenExpiresAt,
+ "refresh_token_expires", refreshTokenCookie.Expires)
+}
+
+// clearTokenCookies removes both access and refresh token cookies.
+//
+// This function clears authentication cookies by setting them to empty values
+// with past expiration dates, effectively removing them from the client.
+//
+// Parameters:
+// - 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)
+
+ // Clear access token cookie
+ accessTokenCookie := &http.Cookie{
+ Name: "auth_token",
+ Value: "",
+ Path: "/",
+ Expires: pastTime,
+ HttpOnly: true,
+ Secure: isSecureContext(config),
+ }
+ c.SetCookie(accessTokenCookie)
+
+ // Clear refresh token cookie
+ refreshTokenCookie := &http.Cookie{
+ Name: "refresh_token",
+ Value: "",
+ Path: "/",
+ Expires: pastTime,
+ HttpOnly: true,
+ Secure: isSecureContext(config),
+ }
+ c.SetCookie(refreshTokenCookie)
+
+ config.GetAuthLogger().Debug("Token cookies cleared")
+}
+
+// getRefreshTokenFromCookie extracts the refresh token from the request cookies.
+//
+// Parameters:
+// - c: The Echo context containing the HTTP request
+//
+// Returns:
+// - string: The refresh token value, or empty string if not found
+func getRefreshTokenFromCookie(c echo.Context) string {
+ refreshCookie, err := c.Cookie("refresh_token")
+ if err != nil || refreshCookie.Value == "" {
+ return ""
+ }
+ return refreshCookie.Value
+}
+
+// isSecureContext determines if cookies should be marked as Secure.
+//
+// In production environments, cookies should be marked as Secure to ensure
+// they are only sent over HTTPS connections.
+//
+// Parameters:
+// - config: Configuration provider for auth settings
+//
+// Returns:
+// - bool: True if cookies should be marked as Secure
+func isSecureContext(config auth.ConfigProvider) bool {
+ // For now, return false to match existing comment in original code
+ // In production, this should return true based on environment detection
+ return false
+}
diff --git a/internal/cognitoauth/handler.go b/internal/cognitoauth/handler.go
index 676d8cd5..d5b748ad 100644
--- a/internal/cognitoauth/handler.go
+++ b/internal/cognitoauth/handler.go
@@ -147,6 +147,69 @@ func exchangeCodeForTokensWithPKCE(authCode, codeVerifier string, config auth.Co
return &tokenResponse, nil
}
+// refreshTokens exchanges a refresh token for new access and refresh tokens.
+//
+// This function implements the OAuth 2.0 refresh token grant flow with AWS Cognito:
+// 1. Builds a token refresh request with the refresh token and client credentials
+// 2. Sends the request to the Cognito token endpoint
+// 3. Processes the response to extract the new tokens
+//
+// Parameters:
+// - refreshToken: The refresh token received during initial authentication
+// - config: Configuration provider that supplies auth-related settings
+//
+// Returns:
+// - A pointer to a TokenResponse containing new ID token, access token, and refresh token
+// - An error if the token refresh fails for any reason
+func refreshTokens(refreshToken string, config auth.ConfigProvider) (*TokenResponse, error) {
+ data := url.Values{}
+ data.Set("grant_type", "refresh_token")
+ data.Set("client_id", config.GetAuthClientID())
+ data.Set("refresh_token", refreshToken)
+
+ config.GetAuthLogger().Debug("Token refresh request details",
+ "url", config.GetAuthTokenURL(),
+ "client_id", config.GetAuthClientID())
+
+ req, err := http.NewRequest("POST", config.GetAuthTokenURL(), strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create token refresh request: %w", err)
+ }
+
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ // Add Authorization header if client secret is provided
+ if config.GetAuthClientSecret() != "" {
+ req.SetBasicAuth(config.GetAuthClientID(), config.GetAuthClientSecret())
+ config.GetAuthLogger().Debug("Using Basic Auth authentication for refresh")
+ } else {
+ config.GetAuthLogger().Debug("Using public client authentication for refresh (no secret)")
+ }
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("token refresh request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read token refresh response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("token refresh endpoint returned status %d: %s", resp.StatusCode, string(body))
+ }
+
+ var tokenResponse TokenResponse
+ if err := json.Unmarshal(body, &tokenResponse); err != nil {
+ return nil, fmt.Errorf("failed to parse token refresh response: %w", err)
+ }
+
+ return &tokenResponse, nil
+}
+
// handleOAuthCallback processes the callback from Cognito after a user has authenticated.
//
// This function completes the OAuth 2.0 authorization code flow with PKCE by:
@@ -231,16 +294,8 @@ func handleOAuthCallback(c echo.Context, config auth.ConfigProvider) error {
// Calculate token expiration time based on token's expires_in value
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
- // Set a cookie with the access token
- tokenCookie := new(http.Cookie)
- tokenCookie.Name = "auth_token"
- tokenCookie.Value = tokens.AccessToken
- tokenCookie.Path = "/"
- tokenCookie.Expires = expiresAt
- tokenCookie.HttpOnly = true // Not accessible via JavaScript
- // In production, set Secure to true
- // tokenCookie.Secure = true
- c.SetCookie(tokenCookie)
+ // Set cookies with both access and refresh tokens
+ setTokenCookies(c, tokens, expiresAt, config)
// Extract username for display purposes (optional)
username, _ := idTokenClaims["cognito:username"].(string)
diff --git a/internal/cognitoauth/middleware.go b/internal/cognitoauth/middleware.go
index 3e9f93d9..370a7497 100644
--- a/internal/cognitoauth/middleware.go
+++ b/internal/cognitoauth/middleware.go
@@ -168,6 +168,46 @@ func TokenValidationMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc {
})
}
+ // Check if token is expired and attempt refresh if needed
+ if isTokenExpired(tokenStr) {
+ config.GetAuthLogger().Debug("Access token expired, attempting refresh")
+
+ refreshToken := getRefreshTokenFromCookie(c)
+ if refreshToken == "" {
+ config.GetAuthLogger().Debug("No refresh token found, redirecting to login")
+ clearTokenCookies(c, config)
+ return c.JSON(http.StatusUnauthorized, map[string]interface{}{
+ "error": "Session expired",
+ "message": "Your session has expired. Please log in again.",
+ "login_url": config.GetAuthLoginPath(),
+ })
+ }
+
+ // Attempt to refresh tokens
+ 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(),
+ })
+ }
+
+ // Update cookies with new tokens
+ newExpiresAt := time.Now().Add(time.Duration(newTokens.ExpiresIn) * time.Second)
+ setTokenCookies(c, newTokens, newExpiresAt, config)
+
+ // Update the token string for subsequent verification
+ tokenStr = newTokens.AccessToken
+
+ // Update the Authorization header for this request
+ c.Request().Header.Set("Authorization", "Bearer "+tokenStr)
+
+ config.GetAuthLogger().Info("Token refreshed successfully")
+ }
+
// Get JWKS (skip in test mode)
var keySet jwk.Set
if !config.GetNoJWTValidation() {
@@ -226,15 +266,9 @@ func JWTAuthMiddleware(config auth.ConfigProvider) echo.MiddlewareFunc {
return next(c)
}
- // if logout then remove the auth cookie
+ // if logout then remove both auth cookies
if c.Path() == config.GetAuthLogoutPath() {
- cookie := new(http.Cookie)
- cookie.Name = "auth_token"
- cookie.Value = ""
- cookie.Path = "/"
- cookie.Expires = time.Now().Add(-1 * time.Hour) // Set expiry in the past
- cookie.HttpOnly = true
- c.SetCookie(cookie)
+ clearTokenCookies(c, config)
}
// Skip for home and logout as they should be accessible without auth
diff --git a/internal/cognitoauth/readme.md b/internal/cognitoauth/readme.md
index 6c9189aa..86a8e0de 100644
--- a/internal/cognitoauth/readme.md
+++ b/internal/cognitoauth/readme.md
@@ -5,12 +5,13 @@ A reusable Go package for AWS Cognito authentication and Permit.io authorization
## Features
- Complete OAuth 2.0 PKCE flow for AWS Cognito authentication
+- **Automatic JWT token refresh** for seamless user experience
- JWT token validation and verification
- Dynamic route-based authorization using Permit.io Policy Decision Point (PDP)
- Automatic resource mapping from HTTP routes to Permit.io resources
- HTTP method to action mapping for authorization checks
- Middleware for token handling and permission enforcement
-- Cookie-based token storage
+- Cookie-based token storage with refresh token support
- Default home page with authentication status
- Simple integration with Echo framework
@@ -79,12 +80,47 @@ The authentication system uses two middleware components that must be registered
2. **`TokenValidationMiddleware`** (Second)
- **Performs authentication**: Validates JWT tokens and extracts user claims
+ - **Automatic token refresh**: Detects expired tokens and refreshes them transparently
- **Performs authorization**: Checks permissions via Permit.io PDP
- Returns JSON error responses for authentication/authorization failures
- Sets user context for subsequent handlers
**Why order matters**: The first middleware prepares the request for token validation, while the second middleware performs the actual **authentication and authorization enforcement**.
+### Automatic Token Refresh
+
+The system provides seamless token refresh functionality to maintain user sessions without interruption:
+
+#### How It Works
+1. **Proactive Refresh**: Tokens are automatically refreshed 5 minutes before expiration
+2. **Transparent Process**: Users never experience session interruptions
+3. **Dual Cookie Storage**: Both access tokens (short-lived) and refresh tokens (30-day) are stored as HTTP-only cookies
+4. **Fallback Handling**: When refresh fails, users are gracefully redirected to login
+
+#### Token Storage Strategy
+- **Access Token Cookie**: `auth_token` - expires with the JWT token (typically 1 hour)
+- **Refresh Token Cookie**: `refresh_token` - longer expiration (30 days)
+- **Security**: Both cookies are HTTP-only and secure in production environments
+
+#### Refresh Process Flow
+1. **Token Expiration Check**: Middleware checks if access token expires within 5 minutes
+2. **Refresh Token Retrieval**: Extracts refresh token from secure cookie
+3. **Token Exchange**: Uses OAuth 2.0 refresh grant to get new tokens from Cognito
+4. **Cookie Update**: Sets new access and refresh tokens in cookies
+5. **Request Continuation**: Processes the original request with fresh tokens
+
+#### Error Handling
+- **Missing Refresh Token**: Redirects to login with "Session expired" message
+- **Invalid Refresh Token**: Clears cookies and redirects to login
+- **Cognito Service Issues**: Logs errors and falls back to re-authentication
+- **Network Errors**: Retries on next request, maintains user experience
+
+#### Session Expiration Scenarios
+- **Access Token Expired + Valid Refresh Token**: Automatic refresh (transparent)
+- **Both Tokens Expired**: Redirect to login with clear messaging
+- **Refresh Token Invalid**: Clear cookies and redirect to login
+- **User Logout**: Both cookies are immediately cleared
+
### Client Caching
The system implements efficient client caching for Permit.io connections:
- **Singleton pattern**: One client instance per unique (apiKey, pdpURL) combination
@@ -120,7 +156,18 @@ The system performs comprehensive validation at startup to ensure proper configu
}
```
- **Status Code**: `401 Unauthorized`
-- **When**: Missing, invalid, or expired JWT tokens
+- **When**: Missing, invalid, or malformed JWT tokens
+
+**Session Expiration Errors** (Token refresh failed):
+```json
+{
+ "error": "Session expired",
+ "message": "Your session has expired. Please log in again.",
+ "login_url": "/login"
+}
+```
+- **Status Code**: `401 Unauthorized`
+- **When**: Access token expired and refresh token is missing or invalid
**Authorization Errors** (Valid token, insufficient permissions):
```json
diff --git a/internal/cognitoauth/summary.md b/internal/cognitoauth/summary.md
index 9e1f418a..d22d9a97 100644
--- a/internal/cognitoauth/summary.md
+++ b/internal/cognitoauth/summary.md
@@ -10,9 +10,9 @@ This package uses AWS Cognito for authentication and Permit.io for dynamic autho
- **auth.go**: Main exports for the package and middleware registration.
- **handler.go**: HTTP handlers for login, callback, logout, and home.
-- **middleware.go**: Echo middleware for JWT extraction, validation, and Permit.io authorization enforcement.
+- **middleware.go**: Echo middleware for JWT extraction, validation, automatic token refresh, and Permit.io authorization enforcement.
- **jwks.go**: Handles JWKS key fetching and JWT signature validation.
-- **token.go**: Token verification and management.
+- **token.go**: Token verification, expiration checking, and refresh token management.
- **models.go**: Data models and types for tokens, users, etc.
- **permitio.go**: Permit.io client integration and authorization logic.
- **mapping.go**: Route-to-resource and HTTP method-to-action mapping.
@@ -58,6 +58,7 @@ The cognito client must be setup to redirect back to /login-callback for PKCE fl
6. **Request Flow**:
- User requests a protected endpoint.
- Middleware extracts JWT from cookies/headers and validates it.
+ - **Token Refresh**: If token expires within 5 minutes, automatically refresh using stored refresh token.
- Route is mapped to resource (first path segment) and HTTP method to action.
- Permit.io PDP is queried: `CheckPermission(user_subject, action, resource)`.
- If authorized, the request proceeds to the handler.
@@ -167,4 +168,35 @@ sequenceDiagram
Cognito->>AppServer: 14. Tokens response
Note over AppServer: 15. Verify tokens
Check permissions
AppServer->>Browser: 16. Authentication successful
-```
\ No newline at end of file
+```
+
+## Token Refresh Flow
+
+The system implements automatic token refresh to maintain seamless user sessions:
+
+### Token Storage Strategy
+- **Access Token**: Short-lived (1 hour), stored in `auth_token` cookie
+- **Refresh Token**: Long-lived (30 days), stored in `refresh_token` cookie
+- **Security**: Both cookies are HTTP-only and secure in production
+
+### Automatic Refresh Process
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant Middleware as TokenValidationMiddleware
+ participant Cognito
+
+ Browser->>Middleware: 1. API Request with expired token
+ Note over Middleware: 2. Check token expiration
(5 min buffer)
+ Middleware->>Middleware: 3. Extract refresh token from cookie
+ Middleware->>Cognito: 4. POST /oauth2/token
grant_type=refresh_token
+ Cognito->>Middleware: 5. New tokens response
+ Note over Middleware: 6. Update cookies with new tokens
Set Authorization header
+ Middleware->>Browser: 7. Process original request
Return response with new cookies
+```
+
+### Key Benefits
+- **Transparent**: Users never experience session interruptions
+- **Proactive**: Tokens refreshed 5 minutes before expiration
+- **Secure**: HTTP-only cookies prevent XSS attacks
+- **Fallback**: Graceful degradation to login when refresh fails
\ No newline at end of file
diff --git a/internal/cognitoauth/token.go b/internal/cognitoauth/token.go
index 34a94150..baf2398f 100644
--- a/internal/cognitoauth/token.go
+++ b/internal/cognitoauth/token.go
@@ -282,3 +282,41 @@ func ExtractUserInfo(claims map[string]interface{}) UserInfo {
return userInfo
}
+
+// isTokenExpired checks if a JWT token is expired or will expire soon.
+//
+// This function parses the JWT token without verification to extract the
+// expiration claim and checks if the token is expired or will expire within
+// a buffer period (5 minutes). This allows for proactive token refresh.
+// For malformed tokens, it returns false to let the normal validation handle them.
+//
+// Parameters:
+// - tokenString: The JWT token string to check
+//
+// Returns:
+// - bool: True if the token is expired or will expire soon, false otherwise
+func isTokenExpired(tokenString string) bool {
+ if tokenString == "" {
+ return false // Let normal validation handle empty tokens
+ }
+
+ // Parse token without signature verification to get claims
+ parsedToken, err := jwt.Parse(
+ []byte(tokenString),
+ jwt.WithVerify(false), // Skip signature verification
+ jwt.WithValidate(false), // Skip expiration validation (we'll check manually)
+ )
+ if err != nil {
+ return false // Let normal validation handle malformed tokens
+ }
+
+ // Extract expiration time
+ exp := parsedToken.Expiration()
+ if exp.IsZero() {
+ return false // Let normal validation handle tokens without expiration
+ }
+
+ // Check if token is expired or will expire within 5 minutes (buffer)
+ bufferTime := 5 * time.Minute
+ return time.Now().Add(bufferTime).After(exp)
+}