auth package WIP
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package cognitoauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// initiateLoginWithPKCE generates PKCE code challenge and redirects to Cognito
|
||||
// This function starts the OAuth authorization flow with PKCE
|
||||
func initiateLoginWithPKCE(c echo.Context, config *Config) error {
|
||||
// Generate random state parameter to prevent CSRF
|
||||
state, err := generateRandomString(32)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to generate state parameter", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
|
||||
}
|
||||
|
||||
// Generate code verifier (random string between 43-128 chars)
|
||||
codeVerifier, err := generateRandomString(64)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to generate code verifier", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Authentication initialization failed"})
|
||||
}
|
||||
|
||||
// Create code challenge from verifier (SHA256 + Base64URL without padding)
|
||||
codeChallenge := createCodeChallenge(codeVerifier)
|
||||
|
||||
// Store in session for later verification (with expiration time)
|
||||
storePKCESession(state, codeVerifier, config.Logger)
|
||||
|
||||
// Build authorization URL with PKCE parameters
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("redirect_uri", config.RedirectURI)
|
||||
params.Set("scope", "openid email profile")
|
||||
params.Set("state", state)
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", config.AuthURL, params.Encode())
|
||||
|
||||
if os.Getenv("DEBUG") == "true" {
|
||||
config.Logger.Debug("Initiating login with PKCE",
|
||||
"code_verifier", codeVerifier,
|
||||
"code_challenge", codeChallenge,
|
||||
"state", state,
|
||||
"redirect_uri", config.RedirectURI)
|
||||
}
|
||||
|
||||
// Redirect user to Cognito login page
|
||||
return c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// exchangeCodeForTokensWithPKCE exchanges the authorization code for tokens using PKCE
|
||||
// Called during the OAuth callback flow to get tokens from the authorization code
|
||||
// Makes an HTTP request to Cognito's token endpoint to perform this exchange
|
||||
func exchangeCodeForTokensWithPKCE(authCode, codeVerifier string, config Config) (*TokenResponse, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("client_id", config.ClientID)
|
||||
data.Set("code", authCode)
|
||||
data.Set("redirect_uri", config.RedirectURI)
|
||||
data.Set("code_verifier", codeVerifier) // Include code verifier for PKCE
|
||||
|
||||
config.Logger.Debug("Token request details",
|
||||
"url", config.TokenURL,
|
||||
"client_id", config.ClientID,
|
||||
"redirect_uri", config.RedirectURI)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Add Authorization header if client secret is provided
|
||||
if config.ClientSecret != "" {
|
||||
req.SetBasicAuth(config.ClientID, config.ClientSecret)
|
||||
config.Logger.Debug("Using Basic Auth authentication")
|
||||
} else {
|
||||
config.Logger.Debug("Using public client authentication (no secret)")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token 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 response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token 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 response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
// handleOAuthCallback handles the OAuth 2.0 authorization code flow callback with PKCE
|
||||
// Called when Cognito redirects back to our application with an authorization code
|
||||
// Exchanges the code for tokens, verifies them, and redirects to home page with token in cookie
|
||||
func handleOAuthCallback(c echo.Context, config *Config) error {
|
||||
// Extract the authorization code and state
|
||||
code := c.QueryParam("code")
|
||||
if code == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing code parameter"})
|
||||
}
|
||||
|
||||
state := c.QueryParam("state")
|
||||
if state == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Missing state parameter"})
|
||||
}
|
||||
|
||||
// Check for OAuth errors
|
||||
errorMsg := c.QueryParam("error")
|
||||
if errorMsg != "" {
|
||||
errorDesc := c.QueryParam("error_description")
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": errorMsg,
|
||||
"error_description": errorDesc,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve stored code verifier
|
||||
codeVerifier, err := getCodeVerifier(state, config.Logger)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to retrieve code verifier", "error", err, "state", state)
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid or expired session"})
|
||||
}
|
||||
|
||||
// Exchange the code for tokens using PKCE
|
||||
tokens, err := exchangeCodeForTokensWithPKCE(code, codeVerifier, *config)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to exchange code for tokens", "error", err)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Get the JWKS for token verification
|
||||
keySet, err := GetJWKS(config.JwksURL, config.Logger)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to fetch JWKS", "error", err)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to verify token"})
|
||||
}
|
||||
|
||||
// Verify ID token
|
||||
idTokenClaims, err := verifyToken(tokens.IDToken, keySet, *config)
|
||||
if err != nil {
|
||||
config.Logger.Error("Failed to verify ID token", "error", err)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
|
||||
}
|
||||
|
||||
// Extract user groups
|
||||
userGroups, err := GetUserGroups(idTokenClaims)
|
||||
if err != nil {
|
||||
config.Logger.Warn("Failed to extract user groups", "error", err)
|
||||
userGroups = []string{} // Initialize as empty array
|
||||
}
|
||||
|
||||
// Debug logging for tokens
|
||||
if os.Getenv("DEBUG") == "true" {
|
||||
// Log decoded token for debugging
|
||||
config.Logger.Info("ID Token Claims", "claims", idTokenClaims)
|
||||
config.Logger.Info("Raw ID Token", "token", tokens.IDToken)
|
||||
config.Logger.Info("Raw Access Token", "token", tokens.AccessToken)
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
authorized, requiredGroups := checkPermissions(config.CallbackPath, userGroups, config.RoutePermissions, config.Logger)
|
||||
if !authorized {
|
||||
config.Logger.Warn("OAuth callback - access denied",
|
||||
"user", idTokenClaims["cognito:username"],
|
||||
"groups", userGroups,
|
||||
"required", requiredGroups)
|
||||
|
||||
return c.JSON(http.StatusForbidden, map[string]interface{}{
|
||||
"error": "Insufficient permissions",
|
||||
"message": "User authenticated successfully but doesn't have the required group membership",
|
||||
"authenticated": true,
|
||||
"username": idTokenClaims["cognito:username"],
|
||||
"email": idTokenClaims["email"],
|
||||
"groups": userGroups,
|
||||
"required_groups": requiredGroups,
|
||||
})
|
||||
}
|
||||
|
||||
// User is authenticated and authorized
|
||||
|
||||
// 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)
|
||||
|
||||
// Extract username for display purposes (optional)
|
||||
username, _ := idTokenClaims["cognito:username"].(string)
|
||||
|
||||
config.Logger.Info("User authenticated successfully", "username", username, "groups", userGroups)
|
||||
|
||||
// Redirect to home page
|
||||
return c.Redirect(http.StatusFound, config.HomePath)
|
||||
}
|
||||
|
||||
// LogoutHandler handles the logout process by clearing the auth cookie
|
||||
func LogoutHandler(c echo.Context, config *Config) error {
|
||||
// Clear the token cookie
|
||||
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)
|
||||
|
||||
// Redirect to home page
|
||||
return c.Redirect(http.StatusFound, config.HomePath)
|
||||
}
|
||||
Reference in New Issue
Block a user