diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index 4c0a3a03..2bb3110e 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -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 { diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go index 371ba481..eda22b92 100644 --- a/openapi/oauth/signing.go +++ b/openapi/oauth/signing.go @@ -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 diff --git a/openapi/oauth/types/errors.go b/openapi/oauth/types/errors.go index 8c2140a0..546b4674 100644 --- a/openapi/oauth/types/errors.go +++ b/openapi/oauth/types/errors.go @@ -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"} diff --git a/openapi/tests/oauth/guard_test.go b/openapi/tests/oauth/guard_test.go new file mode 100644 index 00000000..63a862df --- /dev/null +++ b/openapi/tests/oauth/guard_test.go @@ -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 +} diff --git a/openapi/user/entry.go b/openapi/user/entry.go index 293bf8df..28bbae8f 100644 --- a/openapi/user/entry.go +++ b/openapi/user/entry.go @@ -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, } } diff --git a/openapi/user/login.go b/openapi/user/login.go index 227f190e..b50d1ac6 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -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) diff --git a/openapi/user/oauth.go b/openapi/user/oauth.go index 8bd7bf21..eb4a2612 100644 --- a/openapi/user/oauth.go +++ b/openapi/user/oauth.go @@ -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 diff --git a/openapi/user/types.go b/openapi/user/types.go index 6a07355e..623acad5 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -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"` } diff --git a/sui/api/guards.go b/sui/api/guards.go index b53ce74b..8332af41 100644 --- a/sui/api/guards.go +++ b/sui/api/guards.go @@ -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