in progress

This commit is contained in:
jay brown
2025-07-17 11:43:21 -07:00
parent 6665e6a16e
commit 94718b3d1f
6 changed files with 336 additions and 23 deletions
+107
View File
@@ -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
}
+65 -10
View File
@@ -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)
+42 -8
View File
@@ -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
+49 -2
View File
@@ -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
+35 -3
View File
@@ -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<br/>Check permissions
AppServer->>Browser: 16. Authentication successful
```
```
## 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<br/>(5 min buffer)
Middleware->>Middleware: 3. Extract refresh token from cookie
Middleware->>Cognito: 4. POST /oauth2/token<br/>grant_type=refresh_token
Cognito->>Middleware: 5. New tokens response
Note over Middleware: 6. Update cookies with new tokens<br/>Set Authorization header
Middleware->>Browser: 7. Process original request<br/>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
+38
View File
@@ -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)
}