Enhance OAuth token handling and refresh logic
- Update the `Authenticate` method in the OAuth guard to allow for token refresh when an access token is expired but still valid. - Introduce `TryRefreshToken` method to handle the refresh token logic, including token rotation and cookie management. - Implement `VerifyTokenAllowExpired` and `VerifyRefreshToken` methods to improve token verification processes. - Adjust error handling to provide clearer responses for token refresh failures. - Refactor token expiration strategies in the login process to ensure consistent handling of access and refresh tokens.
This commit is contained in:
parent
88c55b39c0
commit
b68660b3cd
9 changed files with 557 additions and 128 deletions
|
|
@ -47,36 +47,43 @@ func (s *Service) Guard(c *gin.Context) {
|
|||
// This method only performs authentication without ACL checks
|
||||
// Returns true if authentication succeeded, false otherwise
|
||||
func (s *Service) Authenticate(c *gin.Context) bool {
|
||||
// Get the token from the request
|
||||
token := s.getAccessToken(c)
|
||||
|
||||
// Validate the token
|
||||
if token == "" {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
// Try strict verification first (signature + expiration)
|
||||
claims, err := s.VerifyToken(token)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
// Token invalid — check if it's just expired (signature still valid)
|
||||
expiredClaims, expErr := s.VerifyTokenAllowExpired(token)
|
||||
if expErr != nil || expiredClaims == nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
// Auto refresh the token
|
||||
if claims.ExpiresAt.Before(time.Now()) {
|
||||
s.tryAutoRefreshToken(c, claims)
|
||||
if c.IsAborted() {
|
||||
// Signature valid but expired — attempt auto refresh
|
||||
if !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
||||
newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims)
|
||||
if refreshErr != nil {
|
||||
log.Error("[OAuth] Token refresh failed: %v", refreshErr)
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
claims = newClaims
|
||||
} else {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Set Authorized Info in context
|
||||
sessionID := s.getSessionID(c)
|
||||
authorized.SetInfo(c, claims, sessionID, s.UserID)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -86,23 +93,108 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
|
|||
return authorized.GetInfo(c)
|
||||
}
|
||||
|
||||
func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
|
||||
// TryRefreshToken reads the refresh token from the request, verifies it,
|
||||
// rotates the refresh token (revoke old, issue new), issues a new access token,
|
||||
// writes both cookies, and returns the new claims.
|
||||
// expiredClaims may be nil; in that case the identity is derived from the refresh token itself.
|
||||
// Returns (nil, error) on any failure — the caller decides how to respond.
|
||||
func (s *Service) TryRefreshToken(c *gin.Context, expiredClaims *types.TokenClaims) (*types.TokenClaims, error) {
|
||||
refreshToken := s.getRefreshToken(c)
|
||||
if refreshToken == "" {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrRefreshTokenMissing)
|
||||
c.Abort()
|
||||
return
|
||||
return nil, fmt.Errorf("refresh token missing")
|
||||
}
|
||||
|
||||
// Verify the refresh token
|
||||
_, err := s.VerifyToken(refreshToken)
|
||||
refreshClaims, err := s.VerifyRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
|
||||
c.Abort()
|
||||
return
|
||||
return nil, fmt.Errorf("invalid or expired refresh token: %w", err)
|
||||
}
|
||||
|
||||
// @Todo: Auto refresh the token
|
||||
// Derive access token TTL from the expired token's own iat/exp so the refreshed
|
||||
// token keeps the same lifetime that was originally configured at login time.
|
||||
var accessTTL time.Duration
|
||||
if expiredClaims != nil && !expiredClaims.IssuedAt.IsZero() && !expiredClaims.ExpiresAt.IsZero() {
|
||||
accessTTL = expiredClaims.ExpiresAt.Sub(expiredClaims.IssuedAt)
|
||||
}
|
||||
if accessTTL <= 0 {
|
||||
accessTTL = s.config.Token.AccessTokenLifetime
|
||||
}
|
||||
if accessTTL <= 0 {
|
||||
accessTTL = time.Hour
|
||||
}
|
||||
|
||||
// Prefer the expired access token claims; fall back to refresh token claims
|
||||
sourceClaims := expiredClaims
|
||||
if sourceClaims == nil {
|
||||
sourceClaims = refreshClaims
|
||||
}
|
||||
|
||||
extraClaims := sourceClaims.Extra
|
||||
if extraClaims == nil {
|
||||
extraClaims = make(map[string]interface{})
|
||||
}
|
||||
if sourceClaims.TeamID != "" {
|
||||
extraClaims["team_id"] = sourceClaims.TeamID
|
||||
}
|
||||
if sourceClaims.TenantID != "" {
|
||||
extraClaims["tenant_id"] = sourceClaims.TenantID
|
||||
}
|
||||
|
||||
// --- Refresh Token Rotation ---
|
||||
// Revoke the old refresh token so it can never be reused.
|
||||
s.revokeRefreshToken(refreshToken)
|
||||
|
||||
// Calculate remaining refresh lifetime for the new refresh token.
|
||||
var refreshRemainingSeconds int
|
||||
if !refreshClaims.ExpiresAt.IsZero() {
|
||||
refreshRemainingSeconds = int(time.Until(refreshClaims.ExpiresAt).Seconds())
|
||||
if refreshRemainingSeconds <= 0 {
|
||||
return nil, fmt.Errorf("refresh token already expired after revocation")
|
||||
}
|
||||
} else {
|
||||
refreshTTL := s.config.Token.RefreshTokenLifetime
|
||||
if refreshTTL == 0 {
|
||||
refreshTTL = 24 * time.Hour
|
||||
}
|
||||
refreshRemainingSeconds = int(refreshTTL.Seconds())
|
||||
}
|
||||
|
||||
newRefreshToken, err := s.MakeRefreshToken(
|
||||
sourceClaims.ClientID,
|
||||
sourceClaims.Scope,
|
||||
sourceClaims.Subject,
|
||||
refreshRemainingSeconds,
|
||||
extraClaims,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to issue new refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Issue new access token
|
||||
newTokenStr, err := s.MakeAccessToken(
|
||||
sourceClaims.ClientID,
|
||||
sourceClaims.Scope,
|
||||
sourceClaims.Subject,
|
||||
int(accessTTL.Seconds()),
|
||||
extraClaims,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to issue access token: %w", err)
|
||||
}
|
||||
|
||||
// Cookie lifetime = new refresh token lifetime
|
||||
cookieExpires := time.Now().Add(time.Duration(refreshRemainingSeconds) * time.Second)
|
||||
|
||||
cookieValue := fmt.Sprintf("Bearer %s", newTokenStr)
|
||||
response.SendAccessTokenCookieWithExpiry(c, cookieValue, cookieExpires)
|
||||
response.SendRefreshTokenCookieWithExpiry(c, newRefreshToken, cookieExpires)
|
||||
|
||||
newClaims, err := s.VerifyToken(newTokenStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify refreshed token: %w", err)
|
||||
}
|
||||
|
||||
log.Info("[OAuth] Token rotated for subject %s (access + refresh)", sourceClaims.Subject)
|
||||
return newClaims, nil
|
||||
}
|
||||
|
||||
func (s *Service) getAccessToken(c *gin.Context) string {
|
||||
|
|
@ -152,6 +244,11 @@ func (s *Service) GetRefreshToken(c *gin.Context) string {
|
|||
return s.getRefreshToken(c)
|
||||
}
|
||||
|
||||
// GetSessionID gets the session ID from the request (public method)
|
||||
func (s *Service) GetSessionID(c *gin.Context) string {
|
||||
return s.getSessionID(c)
|
||||
}
|
||||
|
||||
// Get Session ID from cookies, headers, or query string
|
||||
func (s *Service) getSessionID(c *gin.Context) string {
|
||||
|
||||
|
|
|
|||
|
|
@ -454,15 +454,64 @@ func (s *Service) SignToken(tokenType, clientID, scope, subject string, expiresI
|
|||
|
||||
// VerifyToken verifies a token based on its format and returns token claims
|
||||
func (s *Service) VerifyToken(token string) (*types.TokenClaims, error) {
|
||||
// First try to verify as JWT (JWT tokens contain dots)
|
||||
if strings.Contains(token, ".") {
|
||||
return s.verifyJWTToken(token)
|
||||
}
|
||||
|
||||
// Otherwise, verify as opaque token
|
||||
return s.verifyOpaqueToken(token)
|
||||
}
|
||||
|
||||
// VerifyTokenAllowExpired verifies token signature but allows expired tokens.
|
||||
// Used by Guard to parse expired access tokens before attempting refresh.
|
||||
func (s *Service) VerifyTokenAllowExpired(token string) (*types.TokenClaims, error) {
|
||||
if strings.Contains(token, ".") {
|
||||
return s.verifyJWTTokenAllowExpired(token)
|
||||
}
|
||||
return s.verifyOpaqueToken(token)
|
||||
}
|
||||
|
||||
// VerifyRefreshToken verifies a refresh token based on its format.
|
||||
// For opaque tokens it looks up the refresh token store (not the access token store).
|
||||
func (s *Service) VerifyRefreshToken(token string) (*types.TokenClaims, error) {
|
||||
if strings.Contains(token, ".") {
|
||||
// JWT refresh tokens can be verified with the same JWT logic
|
||||
return s.verifyJWTToken(token)
|
||||
}
|
||||
return s.verifyOpaqueRefreshToken(token)
|
||||
}
|
||||
|
||||
// verifyOpaqueRefreshToken verifies an opaque refresh token using the refresh token store.
|
||||
func (s *Service) verifyOpaqueRefreshToken(token string) (*types.TokenClaims, error) {
|
||||
tokenInfo, err := s.getRefreshTokenData(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token not found or invalid: %w", err)
|
||||
}
|
||||
|
||||
clientID, _ := tokenInfo["client_id"].(string)
|
||||
scope, _ := tokenInfo["scope"].(string)
|
||||
subject, _ := tokenInfo["subject"].(string)
|
||||
|
||||
claims := &types.TokenClaims{
|
||||
Subject: subject,
|
||||
ClientID: clientID,
|
||||
Scope: scope,
|
||||
TokenType: "refresh_token",
|
||||
Issuer: s.config.IssuerURL,
|
||||
}
|
||||
|
||||
if issuedAt, ok := tokenInfo["issued_at"].(int64); ok {
|
||||
claims.IssuedAt = time.Unix(issuedAt, 0)
|
||||
}
|
||||
|
||||
if expiresAt, ok := tokenInfo["expires_at"].(int64); ok {
|
||||
claims.ExpiresAt = time.Unix(expiresAt, 0)
|
||||
if time.Now().After(claims.ExpiresAt) {
|
||||
return nil, fmt.Errorf("refresh token expired")
|
||||
}
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// SignIDToken signs an ID token with specific parameters and stores it
|
||||
func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *types.OIDCUserInfo, extraClaims ...map[string]interface{}) (string, error) {
|
||||
if s.signingCerts == nil || s.signingCerts.SigningKey == nil {
|
||||
|
|
@ -713,42 +762,57 @@ func (s *Service) signJWTToken(tokenType, clientID, scope, subject string, expir
|
|||
|
||||
// verifyJWTToken verifies a JWT token and returns its claims
|
||||
func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error) {
|
||||
return s.parseJWTToken(tokenString, false)
|
||||
}
|
||||
|
||||
// verifyJWTTokenAllowExpired parses a JWT token, verifying signature but allowing expiration.
|
||||
// Returns claims even if the token is expired (signature must still be valid).
|
||||
func (s *Service) verifyJWTTokenAllowExpired(tokenString string) (*types.TokenClaims, error) {
|
||||
return s.parseJWTToken(tokenString, true)
|
||||
}
|
||||
|
||||
// parseJWTToken is the shared JWT parsing logic.
|
||||
// When allowExpired is true, expired tokens are still parsed (signature-only verification).
|
||||
func (s *Service) parseJWTToken(tokenString string, allowExpired bool) (*types.TokenClaims, error) {
|
||||
if s.signingCerts == nil || s.signingCerts.SigningCert == nil {
|
||||
return nil, fmt.Errorf("signing certificates not initialized")
|
||||
}
|
||||
|
||||
// Parse token with MapClaims to support extra claims
|
||||
parserOpts := []jwt.ParserOption{}
|
||||
if allowExpired {
|
||||
parserOpts = append(parserOpts, jwt.WithoutClaimsValidation())
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate signing method
|
||||
expectedMethod := getSigningMethod(s.config.Token.AccessTokenSigningAlg)
|
||||
if token.Method != expectedMethod {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// Return public key for verification
|
||||
return s.signingCerts.GetPublicKey(), nil
|
||||
})
|
||||
}, parserOpts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
if !allowExpired && !token.Valid {
|
||||
return nil, fmt.Errorf("invalid JWT token")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid JWT claims type")
|
||||
}
|
||||
|
||||
// Convert to TokenClaims
|
||||
return s.extractTokenClaims(mapClaims), nil
|
||||
}
|
||||
|
||||
// extractTokenClaims converts jwt.MapClaims to types.TokenClaims
|
||||
func (s *Service) extractTokenClaims(mapClaims jwt.MapClaims) *types.TokenClaims {
|
||||
tokenClaims := &types.TokenClaims{
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
// Extract standard claims
|
||||
if sub, ok := mapClaims["sub"].(string); ok {
|
||||
tokenClaims.Subject = sub
|
||||
}
|
||||
|
|
@ -768,7 +832,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
|
|||
tokenClaims.JTI = jti
|
||||
}
|
||||
|
||||
// Extract time claims
|
||||
if exp, ok := mapClaims["exp"].(float64); ok {
|
||||
tokenClaims.ExpiresAt = time.Unix(int64(exp), 0)
|
||||
}
|
||||
|
|
@ -776,7 +839,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
|
|||
tokenClaims.IssuedAt = time.Unix(int64(iat), 0)
|
||||
}
|
||||
|
||||
// Extract audience
|
||||
if aud, ok := mapClaims["aud"].(string); ok {
|
||||
tokenClaims.Audience = []string{aud}
|
||||
} else if audArray, ok := mapClaims["aud"].([]interface{}); ok {
|
||||
|
|
@ -789,7 +851,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
|
|||
tokenClaims.Audience = audience
|
||||
}
|
||||
|
||||
// Extract extended claims for multi-tenancy and team support
|
||||
if teamID, ok := mapClaims["team_id"].(string); ok {
|
||||
tokenClaims.TeamID = teamID
|
||||
}
|
||||
|
|
@ -797,7 +858,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
|
|||
tokenClaims.TenantID = tenantID
|
||||
}
|
||||
|
||||
// Store all extra claims for flexibility
|
||||
standardClaims := map[string]bool{
|
||||
"sub": true, "client_id": true, "scope": true, "token_type": true,
|
||||
"exp": true, "iat": true, "nbf": true, "iss": true, "aud": true, "jti": true,
|
||||
|
|
@ -809,7 +869,7 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
|
|||
}
|
||||
}
|
||||
|
||||
return tokenClaims, nil
|
||||
return tokenClaims
|
||||
}
|
||||
|
||||
// signOpaqueToken signs an opaque token using HMAC or RSA signature
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ var (
|
|||
ErrTokenMissing = &ErrorResponse{Code: "token_missing", ErrorDescription: "No access token provided in the request"}
|
||||
ErrInvalidRefreshToken = &ErrorResponse{Code: "invalid_refresh_token", ErrorDescription: "The refresh token provided is invalid or expired"}
|
||||
ErrRefreshTokenMissing = &ErrorResponse{Code: "refresh_token_missing", ErrorDescription: "No refresh token provided in the request"}
|
||||
ErrTokenRefreshFailed = &ErrorResponse{Code: "token_refresh_failed", ErrorDescription: "Failed to refresh access token"}
|
||||
|
||||
// Permission related errors
|
||||
ErrForbidden = &ErrorResponse{Code: "forbidden", ErrorDescription: "You do not have permission to access this resource"}
|
||||
|
|
|
|||
271
openapi/tests/oauth/guard_test.go
Normal file
271
openapi/tests/oauth/guard_test.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
// TestGuard_ValidToken verifies that a valid, non-expired access token passes through authentication.
|
||||
func TestGuard_ValidToken(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Guard Valid Token Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
accessCookieName := response.GetCookieName("access_token")
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", tokenInfo.AccessToken)})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Valid token should pass authentication")
|
||||
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
|
||||
}
|
||||
|
||||
// TestGuard_NoToken verifies that a request without any token is rejected with 401.
|
||||
func TestGuard_NoToken(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "No token should return 401")
|
||||
assert.Contains(t, w.Body.String(), "token_missing", "Error should indicate missing token")
|
||||
}
|
||||
|
||||
// TestGuard_InvalidSignature verifies that a token with an invalid signature is rejected with 401.
|
||||
func TestGuard_InvalidSignature(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
accessCookieName := response.GetCookieName("access_token")
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmYWtlIn0.invalidsignature"})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "Invalid signature should return 401")
|
||||
}
|
||||
|
||||
// TestGuard_ExpiredToken_NoRefresh verifies that an expired access token without a refresh token returns 401.
|
||||
func TestGuard_ExpiredToken_NoRefresh(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Guard Expired No Refresh Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
expiredToken, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", "test-subject-expired", -1)
|
||||
assert.NoError(t, err, "Should be able to create expired token")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
accessCookieName := response.GetCookieName("access_token")
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredToken)})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "Expired token without refresh token should return 401")
|
||||
}
|
||||
|
||||
// TestGuard_ExpiredToken_WithValidRefresh verifies that an expired access token with a valid refresh token
|
||||
// triggers auto-refresh: the request succeeds and a new access_token cookie is set.
|
||||
func TestGuard_ExpiredToken_WithValidRefresh(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Guard Auto Refresh Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
subject := "test-subject-auto-refresh"
|
||||
|
||||
expiredToken, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", subject, -1)
|
||||
assert.NoError(t, err, "Should create expired access token")
|
||||
|
||||
// Create a JWT-format refresh token so VerifyToken can validate it directly.
|
||||
// The default opaque format requires store lookup which is separate from the signing path.
|
||||
refreshToken, err := oauthService.MakeRefreshToken(client.ClientID, "openid profile", subject, 86400)
|
||||
assert.NoError(t, err, "Should create valid refresh token")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
accessCookieName := response.GetCookieName("access_token")
|
||||
refreshCookieName := response.GetCookieName("refresh_token")
|
||||
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredToken)})
|
||||
req.AddCookie(&http.Cookie{Name: refreshCookieName, Value: fmt.Sprintf("Bearer %s", refreshToken)})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Expired token + valid refresh should auto-refresh and succeed")
|
||||
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
|
||||
|
||||
// Verify that both access_token and refresh_token cookies were rotated
|
||||
setCookieHeaders := w.Result().Cookies()
|
||||
foundNewAccessToken := false
|
||||
foundNewRefreshToken := false
|
||||
for _, c := range setCookieHeaders {
|
||||
if c.Name == accessCookieName {
|
||||
foundNewAccessToken = true
|
||||
assert.NotEmpty(t, c.Value, "New access token cookie should have a value")
|
||||
rawValue := strings.TrimPrefix(c.Value, "Bearer ")
|
||||
assert.NotEqual(t, expiredToken, rawValue, "New token should differ from the expired one")
|
||||
t.Logf("New access_token cookie set with MaxAge=%d", c.MaxAge)
|
||||
}
|
||||
if c.Name == refreshCookieName {
|
||||
foundNewRefreshToken = true
|
||||
assert.NotEmpty(t, c.Value, "New refresh token cookie should have a value")
|
||||
rawValue := strings.TrimPrefix(c.Value, "Bearer ")
|
||||
assert.NotEqual(t, refreshToken, rawValue, "New refresh token should differ from the old one")
|
||||
t.Logf("New refresh_token cookie set with MaxAge=%d", c.MaxAge)
|
||||
}
|
||||
}
|
||||
assert.True(t, foundNewAccessToken, "Guard should write a new access_token cookie after auto-refresh")
|
||||
assert.True(t, foundNewRefreshToken, "Guard should rotate refresh_token cookie after auto-refresh")
|
||||
}
|
||||
|
||||
// TestGuard_ExpiredToken_WithExpiredRefresh verifies that an expired access token paired with an
|
||||
// also-expired refresh token returns 401.
|
||||
func TestGuard_ExpiredToken_WithExpiredRefresh(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Guard Expired Refresh Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
subject := "test-subject-both-expired"
|
||||
|
||||
expiredAccess, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", subject, -1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Opaque refresh tokens expire via store TTL, not a field in the data.
|
||||
// Use a 1-second TTL and wait for it to expire from the store.
|
||||
expiredRefresh, err := oauthService.MakeRefreshToken(client.ClientID, "openid profile", subject, 1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
accessCookieName := response.GetCookieName("access_token")
|
||||
refreshCookieName := response.GetCookieName("refresh_token")
|
||||
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredAccess)})
|
||||
req.AddCookie(&http.Cookie{Name: refreshCookieName, Value: fmt.Sprintf("Bearer %s", expiredRefresh)})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "Both tokens expired should return 401")
|
||||
}
|
||||
|
||||
// TestGuard_AuthorizationHeader verifies that the Guard also works with the Authorization header.
|
||||
func TestGuard_AuthorizationHeader(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
_ = serverURL
|
||||
|
||||
oauthService := oauth.OAuth
|
||||
assert.NotNil(t, oauthService, "OAuth service should be initialized")
|
||||
|
||||
client := testutils.RegisterTestClient(t, "Guard Header Auth Test", []string{"https://localhost/callback"})
|
||||
defer testutils.CleanupTestClient(t, client.ClientID)
|
||||
|
||||
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
|
||||
router := authenticateRouter(oauthService)
|
||||
|
||||
req := httptest.NewRequest("GET", "/guarded", nil)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenInfo.AccessToken))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Valid Bearer token in Authorization header should pass authentication")
|
||||
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
|
||||
}
|
||||
|
||||
// authenticateRouter creates a Gin router with ONLY the Authenticate middleware (no ACL).
|
||||
// This isolates the token verification and auto-refresh logic from permission checks.
|
||||
func authenticateRouter(oauthService *oauth.Service) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
info := authorized.GetInfo(c)
|
||||
if info == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "no authorized info"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"subject": info.Subject,
|
||||
"client_id": info.ClientID,
|
||||
"scope": info.Scope,
|
||||
"user_id": info.UserID,
|
||||
"session_id": info.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
// Use Authenticate (auth only) instead of Guard (auth + ACL)
|
||||
router.GET("/guarded", func(c *gin.Context) {
|
||||
if !oauthService.Authenticate(c) {
|
||||
return
|
||||
}
|
||||
handler(c)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
|
@ -471,7 +471,6 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
|||
publicConfig.Token = &TokenConfig{
|
||||
ExpiresIn: config.Token.ExpiresIn,
|
||||
RefreshTokenExpiresIn: config.Token.RefreshTokenExpiresIn,
|
||||
RememberMeExpiresIn: config.Token.RememberMeExpiresIn,
|
||||
RememberMeRefreshTokenExpiresIn: config.Token.RememberMeRefreshTokenExpiresIn,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -500,89 +500,75 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login
|
|||
func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) {
|
||||
yaoClientConfig := GetYaoClientConfig()
|
||||
|
||||
// Determine token expiration times based on Remember Me setting
|
||||
// Token expiration strategy:
|
||||
// - access_token: always short-lived (from expires_in config), same for all login types
|
||||
// - refresh_token: short for normal login, long for remember_me / OAuth
|
||||
// Security: a leaked access_token has limited impact window; "keep logged in"
|
||||
// is achieved by silently refreshing via long-lived refresh_token in Guard.
|
||||
var expiresIn, refreshTokenExpiresIn int
|
||||
|
||||
// Try to get token config from entry config first
|
||||
locale := ""
|
||||
if params.LoginCtx != nil && params.LoginCtx.Locale != "" {
|
||||
locale = params.LoginCtx.Locale
|
||||
}
|
||||
entryConfig := GetEntryConfig(locale)
|
||||
|
||||
if params.LoginCtx != nil && params.LoginCtx.RememberMe {
|
||||
// Remember Me mode: use extended token durations
|
||||
if entryConfig != nil && entryConfig.Token != nil {
|
||||
// Parse Remember Me access token expires_in
|
||||
if entryConfig.Token.RememberMeExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.RememberMeExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse remember_me_expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
expiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Remember Me refresh token expires_in
|
||||
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
refreshTokenExpiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If refresh token not configured, default to 2x the access token duration
|
||||
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
|
||||
refreshTokenExpiresIn = expiresIn * 2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal login: use standard token durations from entry config
|
||||
if entryConfig != nil && entryConfig.Token != nil {
|
||||
// Parse access token expires_in
|
||||
if entryConfig.Token.ExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
expiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse refresh token expires_in
|
||||
if entryConfig.Token.RefreshTokenExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
refreshTokenExpiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If refresh token not configured, default to 24x the access token duration
|
||||
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
|
||||
refreshTokenExpiresIn = expiresIn * 24
|
||||
// 1. Access token: always use the standard short duration
|
||||
if entryConfig != nil && entryConfig.Token != nil && entryConfig.Token.ExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
expiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to YaoClientConfig defaults if not set from entry config
|
||||
// 2. Refresh token: depends on remember_me
|
||||
rememberMe := params.LoginCtx != nil && params.LoginCtx.RememberMe
|
||||
if rememberMe && entryConfig != nil && entryConfig.Token != nil {
|
||||
// Remember Me: use extended refresh token duration
|
||||
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
refreshTokenExpiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if entryConfig != nil && entryConfig.Token != nil {
|
||||
// Normal login: use standard refresh token duration
|
||||
if entryConfig.Token.RefreshTokenExpiresIn != "" {
|
||||
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
|
||||
if err != nil {
|
||||
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
|
||||
} else {
|
||||
duration, err := time.ParseDuration(normalized)
|
||||
if err == nil {
|
||||
refreshTokenExpiresIn = int(duration.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Default fallbacks
|
||||
if expiresIn == 0 {
|
||||
expiresIn = yaoClientConfig.ExpiresIn
|
||||
}
|
||||
// Refresh token defaults: remember_me 90d, normal 7d, then client config
|
||||
if refreshTokenExpiresIn == 0 {
|
||||
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
|
||||
if rememberMe {
|
||||
refreshTokenExpiresIn = 90 * 24 * 3600 // 90 days
|
||||
} else if yaoClientConfig.RefreshTokenExpiresIn > 0 {
|
||||
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
|
||||
} else {
|
||||
refreshTokenExpiresIn = 7 * 24 * 3600 // 7 days
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare OIDC user info
|
||||
|
|
@ -892,9 +878,13 @@ func GinLogout(c *gin.Context) {
|
|||
// This includes access token, refresh token, and optionally session ID cookies with appropriate security settings
|
||||
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
|
||||
|
||||
// Send session ID cookie only if sessionID is provided
|
||||
// Send session ID cookie - expires with refresh token so session survives token refreshes
|
||||
if sessionID != "" {
|
||||
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
|
||||
sessionExpiry := loginResponse.RefreshTokenExpiresIn
|
||||
if sessionExpiry <= 0 {
|
||||
sessionExpiry = loginResponse.ExpiresIn
|
||||
}
|
||||
expires := time.Now().Add(time.Duration(sessionExpiry) * time.Second)
|
||||
options := response.NewSecureCookieOptions().
|
||||
WithExpires(expires).
|
||||
WithSameSite("Strict")
|
||||
|
|
@ -914,10 +904,13 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st
|
|||
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
|
||||
|
||||
// Calculate expiration times
|
||||
// access_token cookie lives as long as refresh_token so the browser keeps sending the
|
||||
// (JWT-expired) access token — the Guard can then use the refresh token to issue a new one.
|
||||
// The JWT's own `exp` claim handles the real expiration check on the server side.
|
||||
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
|
||||
|
||||
// Send access token cookie
|
||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, time.Now().Add(time.Duration(loginResponse.ExpiresIn)*time.Second))
|
||||
// Send access token cookie (cookie lifetime = refresh token lifetime)
|
||||
response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires)
|
||||
|
||||
// Send refresh token cookie
|
||||
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ func authback(c *gin.Context) {
|
|||
// LoginThirdParty(providerID, userInfo)
|
||||
loginCtx := makeLoginContext(c)
|
||||
loginCtx.AuthSource = providerID // Set auth source to provider name (google, github, etc.)
|
||||
loginCtx.RememberMe = true // OAuth login always uses extended token durations
|
||||
|
||||
// Use locale from params, fallback to "en" if not provided
|
||||
locale := params.Locale
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ type CaptchaConfig struct {
|
|||
type TokenConfig struct {
|
||||
ExpiresIn string `json:"expires_in,omitempty"`
|
||||
RefreshTokenExpiresIn string `json:"refresh_token_expires_in,omitempty"`
|
||||
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
|
||||
RememberMeRefreshTokenExpiresIn string `json:"remember_me_refresh_token_expires_in,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -95,8 +96,8 @@ func guardCookieTrace(r *Request) error {
|
|||
}
|
||||
|
||||
// OAuth 2.1 guard - authentication only
|
||||
// This guard validates the token and sets authorized info
|
||||
// ACL checks are performed separately in Run() for API calls
|
||||
// This guard validates the token and sets authorized info.
|
||||
// ACL checks are performed separately in Run() for API calls.
|
||||
// NOTE: This guard does NOT write HTTP responses on failure, so that
|
||||
// the caller (Guard/apiGuard) can handle redirects or custom error responses.
|
||||
func guardOAuth(r *Request) error {
|
||||
|
|
@ -110,23 +111,30 @@ func guardOAuth(r *Request) error {
|
|||
|
||||
c := r.context
|
||||
|
||||
// Check token first without writing response.
|
||||
// oauth.Authenticate() writes JSON + aborts on failure, which prevents
|
||||
// the caller from doing redirects. So we check the token manually first.
|
||||
token := oauth.OAuth.GetAccessToken(c)
|
||||
if token == "" {
|
||||
return fmt.Errorf("Exception|401:Not authenticated")
|
||||
}
|
||||
|
||||
if _, err := oauth.OAuth.VerifyToken(token); err != nil {
|
||||
return fmt.Errorf("Exception|401:Invalid or expired token")
|
||||
claims, err := oauth.OAuth.VerifyToken(token)
|
||||
if err != nil {
|
||||
// Token invalid — check if just expired (signature still valid)
|
||||
expiredClaims, expErr := oauth.OAuth.VerifyTokenAllowExpired(token)
|
||||
if expErr == nil && expiredClaims != nil &&
|
||||
!expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
|
||||
refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims)
|
||||
if refreshErr != nil {
|
||||
return fmt.Errorf("Exception|401:Token expired and refresh failed")
|
||||
}
|
||||
claims = refreshed
|
||||
} else {
|
||||
return fmt.Errorf("Exception|401:Invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
// Token is valid, now call Authenticate to set up the full context
|
||||
// (session ID, authorized info, etc.). This will succeed since token is valid.
|
||||
oauth.OAuth.Authenticate(c)
|
||||
// Set authorized info in context
|
||||
authorized.SetInfo(c, claims, oauth.OAuth.GetSessionID(c), oauth.OAuth.UserID)
|
||||
|
||||
// Get authorized info from context
|
||||
info := authorized.GetInfo(c)
|
||||
if info != nil {
|
||||
r.Sid = info.SessionID
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue