pkce with page
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
)
|
||||
|
||||
// endpointsList keeps track of registered endpoints
|
||||
// This list should be kept in sync with actual route registrations
|
||||
var endpointsList = []string{
|
||||
"/login",
|
||||
LOGIN_CALLBACK_PATH,
|
||||
"/users",
|
||||
"/users/:id",
|
||||
"/orders",
|
||||
"/reports/sales",
|
||||
"/settings",
|
||||
"/api/inventory/update",
|
||||
"/home", // Include the home endpoint itself
|
||||
}
|
||||
|
||||
// homeHandler renders a simple HTML page with links to all endpoints
|
||||
// and displays authentication status based on JWT token
|
||||
func HomeHandler(c echo.Context) error {
|
||||
var linksHTML string
|
||||
baseURL := "http://localhost:8080"
|
||||
|
||||
// Create list items for each endpoint
|
||||
for _, endpoint := range endpointsList {
|
||||
// Skip endpoints with path parameters for direct linking
|
||||
if strings.Contains(endpoint, ":") {
|
||||
displayPath := strings.Replace(endpoint, ":id", "{id}", -1)
|
||||
linksHTML += fmt.Sprintf("<li>%s (requires parameter)</li>\n", displayPath)
|
||||
} else {
|
||||
linksHTML += fmt.Sprintf("<li><a href=\"%s%s\">%s</a></li>\n", baseURL, endpoint, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is authenticated by looking for token in cookie
|
||||
tokenCookie, err := c.Cookie("auth_token")
|
||||
isAuthenticated := (err == nil && tokenCookie.Value != "")
|
||||
|
||||
// Variables for user info
|
||||
username := ""
|
||||
email := ""
|
||||
var userGroups []string
|
||||
|
||||
// If authenticated, try to decode the JWT token to get user info
|
||||
if isAuthenticated {
|
||||
// Parse the JWT token without verification (just to extract info for display)
|
||||
// In a production application, you would want to verify the token here as well
|
||||
token, _ := jwt.Parse([]byte(tokenCookie.Value), jwt.WithVerify(false))
|
||||
if token != nil {
|
||||
claims, _ := token.AsMap(context.Background())
|
||||
username, _ = claims["cognito:username"].(string)
|
||||
email, _ = claims["email"].(string)
|
||||
|
||||
// Try to extract groups
|
||||
if rawGroups, ok := claims["cognito:groups"]; ok {
|
||||
if groups, ok := rawGroups.([]interface{}); ok {
|
||||
userGroups = make([]string, len(groups))
|
||||
for i, g := range groups {
|
||||
userGroups[i] = fmt.Sprint(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create authentication status section
|
||||
var authStatusHTML string
|
||||
if isAuthenticated {
|
||||
authStatusHTML = fmt.Sprintf(`
|
||||
<div class="auth-status authenticated">
|
||||
<h2>Authentication Status: Authenticated</h2>
|
||||
<p><strong>Username:</strong> %s</p>
|
||||
<p><strong>Email:</strong> %s</p>
|
||||
<p><strong>Groups:</strong> %s</p>
|
||||
<p><a href="/logout" class="logout-btn">Logout</a></p>
|
||||
</div>
|
||||
`, username, email, strings.Join(userGroups, ", "))
|
||||
} else {
|
||||
authStatusHTML = `
|
||||
<div class="auth-status unauthenticated">
|
||||
<h2>Authentication Status: Not Authenticated</h2>
|
||||
<p>You are not currently logged in.</p>
|
||||
<p><a href="/login" class="login-btn">Login</a></p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Create the complete HTML page
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Doczy Home</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1, h2 {
|
||||
color: #333;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
ul {
|
||||
margin-top: 20px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.note {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #5bc0de;
|
||||
padding: 10px 15px;
|
||||
margin-top: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.auth-status {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.authenticated {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
}
|
||||
.unauthenticated {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
}
|
||||
.login-btn, .logout-btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.logout-btn {
|
||||
background-color: #d9534f;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Doczy Home</h1>
|
||||
|
||||
%s
|
||||
|
||||
<h2>Available Endpoints</h2>
|
||||
<ul>
|
||||
%s
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<p><strong>Note:</strong> This is a debugging page. Some endpoints require authentication or specific permissions.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, authStatusHTML, linksHTML)
|
||||
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
@@ -75,6 +75,8 @@ var jwksCache = &JWKSCache{
|
||||
ExpiresAt: time.Now(), // Initial state is expired, forcing a fetch on first use
|
||||
}
|
||||
|
||||
const LOGIN_CALLBACK_PATH = "/login-callback"
|
||||
|
||||
// GetJWKS returns the cached JWKS or fetches a new one if needed
|
||||
// This function implements caching logic to minimize external requests
|
||||
// Used during token validation to get the key set for signature verification
|
||||
@@ -124,8 +126,14 @@ func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[strin
|
||||
requestPath := c.Request().URL.Path
|
||||
logger.Debug("Processing request", "path", requestPath, "method", c.Request().Method)
|
||||
|
||||
// Skip token validation for specific public paths
|
||||
if requestPath == "/home" || requestPath == "/logout" {
|
||||
logger.Debug("Skipping token validation for public path", "path", requestPath)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Special case for the OAuth callback path
|
||||
if requestPath == "/query" && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
|
||||
if requestPath == LOGIN_CALLBACK_PATH && (c.QueryParam("code") != "" || c.QueryParam("error") != "") {
|
||||
logger.Debug("Handling OAuth callback",
|
||||
"has_code", c.QueryParam("code") != "",
|
||||
"has_error", c.QueryParam("error") != "")
|
||||
@@ -205,6 +213,56 @@ func TokenValidationMiddleware(config *CognitoConfig, routePermissions map[strin
|
||||
}
|
||||
}
|
||||
|
||||
// JWTAuthMiddleware checks for JWT token in cookies and adds it to the Authorization header
|
||||
// This middleware is simpler than full session management and aligns with JWT's stateless nature
|
||||
func JWTAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Skip for login and callback paths
|
||||
if c.Path() == "/login" || c.Path() == LOGIN_CALLBACK_PATH {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Skip for home and logout as they should be accessible without auth
|
||||
if c.Path() == "/home" || c.Path() == "/logout" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if Authorization header is already set
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
// Authorization header is already set, proceed to the next handler
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Try to get token from cookie
|
||||
tokenCookie, err := c.Cookie("auth_token")
|
||||
if err != nil || tokenCookie.Value == "" {
|
||||
// No token cookie found
|
||||
return c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
|
||||
// Add token to Authorization header
|
||||
c.Request().Header.Set("Authorization", "Bearer "+tokenCookie.Value)
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// logoutHandler for JWT-based authentication simply clears the token cookie
|
||||
func logoutHandler(c echo.Context) 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, "/home")
|
||||
}
|
||||
|
||||
// checkPermissions checks if the user has the required permissions
|
||||
// Used by both the main token validation middleware and the OAuth callback handler
|
||||
// Returns true if authorized, false if not, along with the required groups
|
||||
@@ -290,7 +348,7 @@ func initiateLoginWithPKCE(c echo.Context, config *CognitoConfig, logger *slog.L
|
||||
|
||||
// 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 checks authorization
|
||||
// Exchanges the code for tokens, verifies them, and redirects to home page with token in cookie
|
||||
func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions map[string][]string, logger *slog.Logger) error {
|
||||
// Extract the authorization code and state
|
||||
code := c.QueryParam("code")
|
||||
@@ -357,7 +415,7 @@ func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
authorized, requiredGroups := checkPermissions("/query", userGroups, routePermissions, logger)
|
||||
authorized, requiredGroups := checkPermissions(LOGIN_CALLBACK_PATH, userGroups, routePermissions, logger)
|
||||
if !authorized {
|
||||
logger.Warn("OAuth callback - access denied",
|
||||
"user", idTokenClaims["cognito:username"],
|
||||
@@ -375,17 +433,29 @@ func handleOAuthCallback(c echo.Context, config *CognitoConfig, routePermissions
|
||||
})
|
||||
}
|
||||
|
||||
// User is authenticated and authorized, return tokens and info
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"authenticated": true,
|
||||
"username": idTokenClaims["cognito:username"],
|
||||
"email": idTokenClaims["email"],
|
||||
"groups": userGroups,
|
||||
"id_token": tokens.IDToken,
|
||||
"access_token": tokens.AccessToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": tokens.ExpiresIn,
|
||||
})
|
||||
// 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)
|
||||
|
||||
logger.Info("User authenticated successfully", "username", username, "groups", userGroups)
|
||||
|
||||
// Redirect to home page
|
||||
return c.Redirect(http.StatusFound, "/home")
|
||||
}
|
||||
|
||||
// generateRandomString creates a cryptographically secure random string
|
||||
@@ -628,6 +698,18 @@ func matchRoute(requestPath, routePattern string) bool {
|
||||
return regex.MatchString(requestPath)
|
||||
}
|
||||
|
||||
// Helper function to mask sensitive values
|
||||
// Used for logging sensitive information like client IDs
|
||||
func maskString(s string) string {
|
||||
if s == "" {
|
||||
return "<not set>"
|
||||
}
|
||||
if len(s) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
// main is the entry point of the application
|
||||
// Sets up the Echo server, middleware, routes, and starts listening
|
||||
func main() {
|
||||
@@ -676,7 +758,7 @@ func main() {
|
||||
|
||||
config := &CognitoConfig{
|
||||
ClientID: os.Getenv("COGNITO_CLIENT_ID"),
|
||||
RedirectURI: "http://localhost:8080/query",
|
||||
RedirectURI: "http://localhost:8080" + LOGIN_CALLBACK_PATH,
|
||||
TokenURL: fmt.Sprintf("https://%s/oauth2/token", domain),
|
||||
AuthURL: fmt.Sprintf("https://%s/oauth2/authorize", domain),
|
||||
// actual https://cognito-idp.us-east-2.amazonaws.com/us-east-2_1y6po8rR8/.well-known/jwks.json
|
||||
@@ -691,12 +773,19 @@ func main() {
|
||||
"/orders": {"exporters", "exporters"},
|
||||
"/reports/sales": {"exporters", "uploaders", "querybuilders"},
|
||||
"/settings": {"exporters"},
|
||||
"/query": {"exporters", "querybuilders"}, // Only exporters can access /query
|
||||
LOGIN_CALLBACK_PATH: {"exporters", "uploaders", "querybuilders"}, // Only exporters can access
|
||||
"/api/inventory/update": {"exporters", "uploaders"},
|
||||
"/login": {}, // No permissions required for login
|
||||
"/login": {"exporters", "uploaders", "querybuilders"}, // No permissions required for login
|
||||
"/home": {"exporters", "uploaders", "querybuilders"}, // No permissions required for home page
|
||||
"/logout": {"exporters", "uploaders", "querybuilders"}, // No permissions required for logout
|
||||
}
|
||||
|
||||
// Apply token validation middleware to all routes except the login route
|
||||
// Apply JWT auth middleware first
|
||||
// This will extract the JWT token from cookies and add it to the Authorization header
|
||||
e.Use(JWTAuthMiddleware)
|
||||
|
||||
// Then apply token validation middleware
|
||||
// This will validate the token from the Authorization header
|
||||
e.Use(TokenValidationMiddleware(config, routePermissions, logger))
|
||||
|
||||
// Login route
|
||||
@@ -705,8 +794,11 @@ func main() {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Logout route
|
||||
e.GET("/logout", logoutHandler)
|
||||
|
||||
// Callback endpoint - handled by middleware
|
||||
e.GET("/query", func(c echo.Context) error {
|
||||
e.GET(LOGIN_CALLBACK_PATH, func(c echo.Context) error {
|
||||
// This handler will only be called for requests that don't have a code parameter
|
||||
// (those are handled directly in the middleware)
|
||||
|
||||
@@ -748,6 +840,9 @@ func main() {
|
||||
return c.String(http.StatusOK, "Inventory update endpoint")
|
||||
})
|
||||
|
||||
// Home endpoint for debugging - shows all available endpoints
|
||||
e.GET("/home", HomeHandler)
|
||||
|
||||
// Print startup information
|
||||
logger.Info("Server configuration",
|
||||
"client_id", maskString(config.ClientID),
|
||||
@@ -755,7 +850,7 @@ func main() {
|
||||
"user_pool_id", userPoolID,
|
||||
"region", region,
|
||||
"debug_mode", os.Getenv("DEBUG") == "true",
|
||||
"auth_method", "PKCE")
|
||||
"auth_method", "PKCE with JWT cookie")
|
||||
|
||||
// Configure server
|
||||
server := &http.Server{
|
||||
@@ -770,15 +865,3 @@ func main() {
|
||||
logger.Info("Server starting on :8080")
|
||||
e.Logger.Fatal(e.StartServer(server))
|
||||
}
|
||||
|
||||
// Helper function to mask sensitive values
|
||||
// Used for logging sensitive information like client IDs
|
||||
func maskString(s string) string {
|
||||
if s == "" {
|
||||
return "<not set>"
|
||||
}
|
||||
if len(s) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ Assumed the cognito user pool is already created.
|
||||
```
|
||||
aws --profile aarete --region us-east-2 --endpoint-url https://cognito-idp.us-east-2.amazonaws.com cognito-idp create-user-pool-client \
|
||||
--user-pool-id us-east-2_1y6po8rR8 \
|
||||
--client-name "public-pkce-client" \
|
||||
--client-name "public-pkce-client-2" \
|
||||
--no-generate-secret \
|
||||
--refresh-token-validity 5 \
|
||||
--access-token-validity 60 \
|
||||
@@ -70,7 +70,7 @@ aws --profile aarete --region us-east-2 --endpoint-url https://cognito-idp.us-ea
|
||||
--token-validity-units "RefreshToken=days,IdToken=minutes,AccessToken=minutes" \
|
||||
--explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH ALLOW_USER_AUTH ALLOW_USER_SRP_AUTH \
|
||||
--supported-identity-providers COGNITO \
|
||||
--callback-urls http://localhost:8080/query \
|
||||
--callback-urls http://localhost:8080/login-callback \
|
||||
--allowed-o-auth-flows code \
|
||||
--allowed-o-auth-scopes email openid phone profile \
|
||||
--allowed-o-auth-flows-user-pool-client \
|
||||
@@ -181,10 +181,11 @@ Users must belong to the required groups to access protected routes.
|
||||
```
|
||||
|
||||
## Mermaid Sequence Diagram
|
||||
Web version [here:](https://mermaid.live/edit#pako:eNp9VE2P2jAQ_SsjH3pisyFAAlHLimVXvfQDle0eKqTK6wxgAXZqO7sFxH_vOISvhZJDlIyf37x5M_aaCZ0hS5nFPwUqgQ-STwxfjBTQk3PjpJA5Vw7ujX6zaM4Xenk-RPOKBriFBy1Wy14u7x_PgX09UdLp7cL2XZHedLt7lhTqAXx-fILbuZ5ItcV90w5B-xxHuIhwqNBwWvNV_KaoHEs0H1_Mbfd0SUz5fI5qglu-PQtlrjSk0AjgB2bSoHDg9Kncg9AqnEKzkql54abRrX9rI1d4FwTBpZyHGvYUrQAGRgu0Fvx2ML4J1m3xFepYYRzsovDFmwMDvmO_IDAJ4CeFQBjMUDnJ5_a_StoBPPO5zErD3uMvKOkcefUmSbqv-FpPw-Om3ghy5oWL2Z3f9okMu9Lmet3nckbi67s-n_dyX0-dhmPwfXhoj9MzVOVg7FJ-OOG6JOLA1ji2h7YBVxmcyjiYdKydhuTJZ7bUW5trZfFapTQPz550CaVcW-rtT1HMIEezkNZKorgywnWakB7V69snuCM02EL4ARsXc1ZjC2LhMqPzvvYsI0bQBY5YSp8ZN7MRG6kN4cgzPVwqwVJnCqwxo4vJlKVjGgn6K3JvRHVT7CB0xH9pvf-l4XDafN1eLuUdU2MT41NXjKgyNH1dKMfSZrPcz9I1-8vSOtndasZxmMRxsxWGUVxjSwJFQScMwySs4smmxlZlxjDoxI0wSpJ2GLXaSdSMNv8APJ-aTA)
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant AppServer as App Server
|
||||
participant AppServer as DoczyApiBE
|
||||
participant Cognito
|
||||
|
||||
Browser->>AppServer: 1. GET /login
|
||||
@@ -196,7 +197,7 @@ sequenceDiagram
|
||||
Browser->>Cognito: 7. User credentials
|
||||
Note over Cognito: 8. Validate credentials
|
||||
Cognito->>Browser: 9. Redirect with code
|
||||
Browser->>AppServer: 10. GET /query?code=...
|
||||
Browser->>AppServer: 10. GET /login-callback?code=...
|
||||
Note over AppServer: 11. Retrieve code_verifier
|
||||
AppServer->>Cognito: 12. POST /oauth2/token<br/>code=...&code_verifier=...
|
||||
Note over Cognito: 13. Validate code and verifier
|
||||
|
||||
Reference in New Issue
Block a user