From 88c55b39c07a45d6d593e395540a2188dcf34a7f Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 16:11:26 +0800 Subject: [PATCH 1/5] Enhance configuration path resolution in LoadWithRoot function - Update the LoadWithRoot function in config.go to ensure DataRoot is set correctly when it is empty, and resolve relative database paths for SQLite3 driver based on the Root directory. - Improve handling of non-absolute paths for primary and secondary database connections, enhancing overall configuration management. --- config/config.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/config/config.go b/config/config.go index ec1e860c..96f8a373 100644 --- a/config/config.go +++ b/config/config.go @@ -138,8 +138,20 @@ func LoadWithRoot(root string) Config { // DataRoot if cfg.DataRoot == "" { cfg.DataRoot = filepath.Join(cfg.Root, "data") - if !filepath.IsAbs(cfg.DataRoot) { - cfg.DataRoot, _ = filepath.Abs(cfg.DataRoot) + } + if !filepath.IsAbs(cfg.DataRoot) { + cfg.DataRoot = filepath.Join(cfg.Root, cfg.DataRoot) + } + + // Resolve DB relative paths based on Root + for i, dsn := range cfg.DB.Primary { + if !filepath.IsAbs(dsn) && (cfg.DB.Driver == "sqlite3" || cfg.DB.Driver == "") { + cfg.DB.Primary[i] = filepath.Join(cfg.Root, dsn) + } + } + for i, dsn := range cfg.DB.Secondary { + if !filepath.IsAbs(dsn) && (cfg.DB.Driver == "sqlite3" || cfg.DB.Driver == "") { + cfg.DB.Secondary[i] = filepath.Join(cfg.Root, dsn) } } From b68660b3cd378aac28f8b6b6cb72e0af4bebfb66 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 18:06:27 +0800 Subject: [PATCH 2/5] 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. --- openapi/oauth/guard.go | 145 +++++++++++++--- openapi/oauth/signing.go | 94 +++++++++-- openapi/oauth/types/errors.go | 1 + openapi/tests/oauth/guard_test.go | 271 ++++++++++++++++++++++++++++++ openapi/user/entry.go | 1 - openapi/user/login.go | 141 ++++++++-------- openapi/user/oauth.go | 1 + openapi/user/types.go | 1 - sui/api/guards.go | 30 ++-- 9 files changed, 557 insertions(+), 128 deletions(-) create mode 100644 openapi/tests/oauth/guard_test.go 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 From 37ab2bc84b88011e4d920ea328a67fab3f24e09b Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 21:38:39 +0800 Subject: [PATCH 3/5] Enhance user login functionality with options for customization - Introduce `LoginWithOptions` method to allow for customizable login flows, enabling overrides for scopes, token expiration, and refresh token issuance. - Add `LoginOptions` struct to encapsulate optional parameters for login, improving flexibility in user authentication. - Update token handling in `issueTokens` to accommodate new options, ensuring proper management of access and refresh tokens based on user preferences. - Integrate OTP service initialization into the OpenAPI server setup for enhanced authentication capabilities. --- go.mod | 27 +- go.sum | 73 +++- openapi/oauth/oauth.go | 5 + openapi/openapi.go | 7 + openapi/otp/DESIGN.md | 334 +++++++++++++++ openapi/otp/README.md | 101 +++++ openapi/otp/generate.go | 70 +++ openapi/otp/handler.go | 214 ++++++++++ openapi/otp/login.go | 95 +++++ openapi/otp/otp.go | 81 ++++ openapi/otp/process.go | 92 ++++ openapi/otp/revoke.go | 12 + openapi/otp/verify.go | 76 ++++ openapi/tests/otp/otp_test.go | 777 ++++++++++++++++++++++++++++++++++ openapi/user/login.go | 156 ++++++- openapi/user/types.go | 27 +- 16 files changed, 2087 insertions(+), 60 deletions(-) create mode 100644 openapi/otp/DESIGN.md create mode 100644 openapi/otp/README.md create mode 100644 openapi/otp/generate.go create mode 100644 openapi/otp/handler.go create mode 100644 openapi/otp/login.go create mode 100644 openapi/otp/otp.go create mode 100644 openapi/otp/process.go create mode 100644 openapi/otp/revoke.go create mode 100644 openapi/otp/verify.go create mode 100644 openapi/tests/otp/otp_test.go diff --git a/go.mod b/go.mod index 71cca5a6..4b25c0cf 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/caarlos0/env/v6 v6.10.1 github.com/dchest/captcha v1.1.0 + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.5.0 github.com/elazarl/go-bindata-assetfs v1.0.1 github.com/emersion/go-imap v1.2.1 github.com/evanw/esbuild v0.25.4 @@ -19,6 +21,7 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/joho/godotenv v1.5.1 github.com/json-iterator/go v1.1.12 @@ -46,7 +49,7 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.1.1 // indirect github.com/JohannesKaufmann/dom v0.2.0 // indirect github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -73,8 +76,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -96,7 +97,6 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.6.3 // indirect @@ -122,6 +122,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/miekg/dns v1.1.66 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect @@ -163,11 +164,14 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark v1.7.16 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/image v0.29.0 // indirect golang.org/x/mod v0.29.0 // indirect @@ -175,10 +179,11 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gotest.tools/v3 v3.5.2 // indirect ) // go env -w GOPRIVATE=github.com/yaoapp/* diff --git a/go.sum b/go.sum index a7118d72..52182b71 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA= @@ -55,6 +58,8 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= @@ -64,6 +69,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -161,6 +168,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -243,6 +252,12 @@ github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -250,6 +265,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ= github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY= @@ -296,8 +313,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= @@ -377,20 +394,26 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -486,6 +509,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -496,14 +521,18 @@ golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -521,4 +550,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index cbc9626a..c4c85085 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -179,6 +179,11 @@ func (s *Service) GetStore() store.Store { return s.store } +// GetKeyPrefix returns the key prefix used for store keys (e.g. "yao_:") +func (s *Service) GetKeyPrefix() string { + return s.prefix +} + // GetSecurityConfig returns the security configuration for the service func (s *Service) GetSecurityConfig() types.SecurityConfig { if s.config == nil { diff --git a/openapi/openapi.go b/openapi/openapi.go index 32c0e64e..505be624 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -21,6 +21,7 @@ import ( "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/otp" "github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/sandbox" "github.com/yaoapp/yao/openapi/team" @@ -86,6 +87,9 @@ func Load(appConfig config.Config) (*OpenAPI, error) { return nil, err } + // Initialize OTP service (shares the OAuth store) + otp.NewService(oauthService.GetStore(), oauthService.GetKeyPrefix()) + // Create the OpenAPI server Server = &OpenAPI{Config: &config, OAuth: oauthService} return Server, nil @@ -160,6 +164,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // App handlers (menu, etc.) app.Attach(group.Group("/app"), openapi.OAuth) + // OTP handlers (passwordless authentication) + otp.Attach(group.Group("/otp"), openapi.OAuth) + // Sandbox handlers (VNC proxy for visual browser automation) sandbox.SetPathPrefix(baseURL) sandbox.Attach(group.Group("/sandbox"), openapi.OAuth) diff --git a/openapi/otp/DESIGN.md b/openapi/otp/DESIGN.md new file mode 100644 index 00000000..4e854608 --- /dev/null +++ b/openapi/otp/DESIGN.md @@ -0,0 +1,334 @@ +# OTP Passwordless Authentication + +## Overview + +OTP (One-Time Password) provides passwordless authentication via magic links. +AI or system generates a short link `https://host//v/`, user clicks it, +CUI verifies the code against the backend, backend issues tokens, and frontend redirects. + +## Architecture + +``` +AI/System Yao Backend CUI Frontend + | | | + |-- otp.Create(params) ------->| | + |<---- code (nanoid 12) -------| | + | | | + | (compose link, send to user) | | + | | | + | | GET //v/ | + | |<-------------------------------| + | | | + | | POST /api/otp/login {code} | + | |<-------------------------------| + | | | + | |-- otp.Login(code) ------------>| + | | ├─ Verify code in store | + | | ├─ Resolve identity | + | | ├─ LoginByTeamID → tokens | + | | └─ SendLoginCookies | + | | | + | | {redirect} ----------------->| + | | | + | | window.location = redirect +``` + +## Design Principles + +1. **Four clean APIs**: Create, Verify, Login, Revoke — each with a single responsibility. +2. **Verify is pure**: returns stored payload, no side effects. + Login is the full flow: verify + identity resolution + token issuance. +3. **Package location `openapi/otp/`**: can freely import `user` and `oauth`. + Dependency chain is one-directional: `openapi/otp → user → oauth` (no cycles). +4. **Shared store, unified key namespace**: reuses OAuth's store.Store, + keys under `{prefix}oauth:otp:{code}` alongside refresh_token/access_token. + +## Package Structure + +``` +openapi/ + otp/ + DESIGN.md ← this file + otp.go ← Service, Payload, NewService + generate.go ← Create (nanoid + collision check + store.Set) + verify.go ← Verify (store.Get + type coercion) + revoke.go ← Revoke (store.Del) + login.go ← Login (Verify + resolve identity + issue tokens) + handler.go ← GinOTPCreate + GinOTPLogin HTTP handlers + Attach(group, oauth) + process.go ← Yao processors: otp.Create, otp.Verify, otp.Login, otp.Revoke + + openapi.go ← init OTP service in Load(), register route in Attach() +``` + +``` +cui/packages/cui/ + openapi/user/auth.ts ← add OTPLogin method + pages/auth/v/$.tsx ← OTP verification page (route: /v/) +``` + +## Dependency Graph + +``` +openapi/otp + ├── imports user (LoginByTeamID, LoginWithOptions, SendLoginCookies) + ├── imports oauth (OAuth.GetUserProvider, OAuth.GetStore for identity resolution) + └── imports store (store.Store for code persistence) + +user + └── imports oauth (existing, unchanged) +``` + +One-directional: `openapi/otp → user → oauth`. No cycles. + +## Data Structures + +### Payload (stored in store) + +```go +type Payload struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` +} +``` + +### GenerateParams + +```go +type GenerateParams struct { + TeamID string + MemberID string + UserID string + ExpiresIn int // seconds, default 24h + Redirect string // required + Scope string // optional, space-separated +} +``` + +## Store Key Format + +``` +{prefix}oauth:otp:{code} +``` + +Example: `yao_:oauth:otp:abc123def456` + +Consistent with existing OAuth keys: +- `{prefix}oauth:refresh_token:{token}` +- `{prefix}oauth:access_token:{token}` +- `{prefix}oauth:otp:{code}` + +NanoID: 12 chars, alphabet `23456789abcdefghjkmnpqrstuvwxyz` (no ambiguous chars). +Collision check: retry up to 5 times. + +## Processor Interface + +Four processors, CRUD-style naming. + +### otp.Create + +```javascript +code = Process("otp.Create", { + "user_id": "user_xxx", // required (or member_id) + "team_id": "team_xxx", // optional + "member_id": "member_xxx", // optional; when set, team_id is required + "expires_in": 86400, // optional; seconds, default 24h + "redirect": "/chat", // required; target path after login + "scope": "read write" // optional; space-separated scopes +}) +// returns: "abc123def456" (string) +// developer composes the full link: `${host}/${prefix}/v/${code}` +``` + +Single map argument. Returns code string. + +### otp.Verify + +```javascript +payload = Process("otp.Verify", "abc123def456") +// returns: { +// "team_id": "team_xxx", +// "member_id": "member_xxx", +// "user_id": "user_xxx", +// "redirect": "/chat", +// "scope": "read write" +// } +``` + +Pure validation. Returns stored Payload. Does NOT consume code (valid within TTL). +Use case: inspect payload before login, or use OTP for non-login purposes. + +### otp.Login + +```javascript +result = Process("otp.Login", "abc123def456", "zh-CN") +// returns: { +// "access_token": "Bearer ...", +// "id_token": "eyJ...", +// "redirect": "/chat", +// "expires_in": 3600, +// ... +// } +``` + +Full login flow: verify code -> resolve identity -> issue tokens. +- args[0]: code (string, required) +- args[1]: locale (string, optional) + +Internally: +1. Verify(code) -> Payload +2. Resolve identity (member_id -> user_id if needed) +3. LoginByTeamID or LoginWithOptions (when scope override) +4. Return LoginResponse + redirect + +Does NOT set HTTP cookies (no gin.Context). The HTTP handler wraps this +and additionally calls SendLoginCookies. + +### otp.Revoke + +```javascript +Process("otp.Revoke", "abc123def456") +// returns: null +``` + +Immediately removes code from store. Silent on missing/expired. + +## HTTP APIs + +### POST /api/otp/create (protected) + +Requires authentication (OpenAPI Guard). Permission managed by Scope/ACL. +The caller's `team_id` is forced from the authenticated identity; request body `team_id` is ignored. +The `member_id` must belong to the caller's team. + +**Request:** +```json +{ + "member_id": "member_xxx", + "user_id": "user_xxx", + "expires_in": 86400, + "redirect": "/chat", + "scope": "read write" +} +``` + +**Success (200):** +```json +{ "code": "abc123def456" } +``` + +**Errors:** 400 (missing fields), 403 (member not in team), 500 (internal). + +### POST /api/otp/login (public) + +Public endpoint (no auth guard). The OTP code itself is the credential. + +**Request:** +```json +{ "code": "abc123def456", "locale": "zh-CN" } +``` + +**Success (200):** +```json +{ "redirect": "/chat" } +``` +Cookies set: `access_token`, `refresh_token`, `session_id`. + +**Errors:** 400 (missing code), 401 (invalid/expired), 500 (internal). + +### Handler Flows (handler.go) + +#### GinOTPCreate +``` +1. authorized.GetInfo(c) -> authInfo (teamID, userID) +2. Bind JSON -> request body +3. Force teamID from authInfo +4. Validate member belongs to team +5. service.Create(params) -> code +6. Respond {code} +``` + +#### GinOTPLogin +``` +1. Bind JSON -> {code, locale} +2. service.Login(code, locale) -> LoginResponse + Payload +3. sessionID = utils.GetSessionID(c) or generateSessionID() +4. user.SendLoginCookies(c, loginResp, sessionID) +5. Respond {redirect: payload.Redirect} +``` + +The handlers are thin — business logic lives in the Service methods. + +## Scope Override (user package change) + +When `payload.Scope` is non-empty, `LoginByTeamID` cannot be used directly +because it resolves scopes internally. Add one function to `user` package: + +```go +// user/types.go +type LoginOptions struct { + Scopes []string +} + +// user/login.go +func LoginWithOptions(userid, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) +``` + +Same logic as `LoginByTeamID`, uses `opts.Scopes` when non-nil. +This is the **only** change to `user` package. + +## Initialization (openapi.go) + +In `Load()`: +```go +otp.NewService(oauth.OAuth.GetStore(), oauth.OAuth.GetPrefix()) +``` + +Note: Since oauth.Service.prefix is private, the OTP service constructs +the prefix independently using `share.App.Prefix` for consistency. + +In `Attach()`: +```go +otp.Attach(group.Group("/otp"), openapi.OAuth) +``` + +## CUI Page (pages/auth/v/$.tsx) + +``` +1. Extract code from URL path: /v/ +2. Call userClient.auth.OTPLogin(code, locale) +3. On success: + - Call GetProfile() to get UserInfo (cookies already set by backend) + - AfterLogin(global, { user: profileData, entry: redirect }) + - window.location.href = redirect +4. On error: show error UI with "Go Back" button +``` + +### auth.ts Addition + +```typescript +async OTPLogin(code: string, locale?: string): Promise> { + return this.api.Post<{ redirect: string }>('/otp/login', { code, locale: locale || '' }) +} +``` + +## Test Plan + +- **Unit tests** (openapi/otp package): + - Create: produces 12-char code, stores payload, respects TTL + - Create: validates required fields (user_id/member_id, redirect) + - Create: handles collision retry + - Verify: returns payload, rejects expired/invalid/empty + - Verify: does NOT consume code (multi-verify within TTL) + - Revoke: removes code, silent on missing + - Login: full flow with valid code returns tokens + - Login: rejects invalid code +- **Handler tests**: + - POST /api/otp/create with valid auth -> 200 + code + - POST /api/otp/create without auth -> 401 + - POST /api/otp/create with cross-team member -> 403 + - POST /api/otp/login with valid code -> 200 + cookies + redirect + - POST /api/otp/login with invalid code -> 401 + - POST /api/otp/login with missing code -> 400 diff --git a/openapi/otp/README.md b/openapi/otp/README.md new file mode 100644 index 00000000..571be47e --- /dev/null +++ b/openapi/otp/README.md @@ -0,0 +1,101 @@ +# OTP — Passwordless Authentication + +One-time password (OTP) module for passwordless login. An authorized caller generates a short-lived code bound to a user/member and a redirect URL. The recipient opens `/v/` in a browser to authenticate without credentials. + +## Process + +| Process | Args | Returns | Description | +|---|---|---|---| +| `otp.Create` | `params` (map) | code (string) | Generate an OTP code | +| `otp.Verify` | `code` | payload (map) | Look up a code without consuming it | +| `otp.Login` | `code`, `locale?` | LoginResult | Verify code, issue access token, optionally consume | +| `otp.Revoke` | `code` | nil | Delete a code immediately | + +### otp.Create + +``` +yao run otp.Create '::{"team_id":"T1","member_id":"M1","redirect":"/dashboard"}' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `team_id` | string | When `member_id` set | — | Team context | +| `member_id` | string | Either this or `user_id` | — | Target member (resolved to user_id at login) | +| `user_id` | string | Either this or `member_id` | — | Target user | +| `redirect` | string | Yes | — | Post-login redirect URL | +| `expires_in` | int | No | 86400 | Code TTL in seconds | +| `token_expires_in` | int | No | system default | Access token lifetime override (seconds) | +| `scope` | string | No | — | Space-separated scopes for the issued token | +| `consume` | bool | No | true | Revoke code after first login | + +### otp.Verify + +``` +yao run otp.Verify abc123def456 +``` + +Returns the stored payload without consuming the code. + +### otp.Login + +``` +yao run otp.Login abc123def456 en-US +``` + +Verifies the code, resolves identity (`member_id` → `user_id` if needed), issues an access token (no refresh token), and returns `LoginResult`. Consumes the code if `consume` is true. + +### otp.Revoke + +``` +yao run otp.Revoke abc123def456 +``` + +Deletes the code. Silent if the code does not exist. + +## HTTP API + +| Method | Path | Auth | Description | +|---|---|---|---| +| `POST` | `/otp/create` | Bearer token (team context) | Create an OTP code | +| `POST` | `/otp/login` | Public | Verify code, set session cookies | + +### POST /otp/create + +Requires a valid access token with team context. The `team_id` is forced from the caller's identity. Validates that the target user/member belongs to the same team. + +**Request:** +```json +{"member_id": "M1", "redirect": "/agents/keeper/entry/xxx"} +``` + +**Response:** +```json +{"code": "abc123def456"} +``` + +### POST /otp/login + +Public endpoint. Checks for an existing valid session first — if found, returns `already_logged_in` without issuing new tokens. Otherwise performs login and sets `access_token` cookie (no `refresh_token`). + +**Request:** +```json +{"code": "abc123def456", "locale": "en-US"} +``` + +**Response:** +```json +{"status": "success", "redirect": "/agents/keeper/entry/xxx"} +``` + +Status is either `success` (new session) or `already_logged_in` (existing session). + +## Security + +- Codes are 12-char NanoID (`[2-9a-hjkmnp-z]`), ~62 bits of entropy +- Default TTL: 24 hours +- Codes are single-use by default (`consume: true`) +- No refresh token issued — access token only +- `POST /otp/create` enforces team membership validation +- `team_id` is always derived from the caller's token, not the request body diff --git a/openapi/otp/generate.go b/openapi/otp/generate.go new file mode 100644 index 00000000..00dc0343 --- /dev/null +++ b/openapi/otp/generate.go @@ -0,0 +1,70 @@ +package otp + +import ( + "fmt" + + nanoid "github.com/matoous/go-nanoid/v2" +) + +// Create generates a new OTP code, stores the payload, and returns the code. +// It validates required fields and retries on NanoID collision. +func (s *Service) Create(params *GenerateParams) (string, error) { + if err := validateCreateParams(params); err != nil { + return "", err + } + + data := map[string]interface{}{ + "redirect": params.Redirect, + "consume": params.Consume, + } + if params.TeamID != "" { + data["team_id"] = params.TeamID + } + if params.MemberID != "" { + data["member_id"] = params.MemberID + } + if params.UserID != "" { + data["user_id"] = params.UserID + } + if params.Scope != "" { + data["scope"] = params.Scope + } + if params.TokenExpiresIn != 0 { + data["token_expires_in"] = params.TokenExpiresIn + } + + for i := 0; i < maxCollisionRetry; i++ { + code, err := nanoid.Generate(codeAlphabet, codeLength) + if err != nil { + return "", fmt.Errorf("failed to generate OTP code: %w", err) + } + + key := s.storeKey(code) + if s.store.Has(key) { + continue + } + + if err := s.store.Set(key, data, ttl(params.ExpiresIn)); err != nil { + return "", fmt.Errorf("failed to store OTP code: %w", err) + } + return code, nil + } + + return "", fmt.Errorf("failed to generate unique OTP code after %d attempts", maxCollisionRetry) +} + +func validateCreateParams(p *GenerateParams) error { + if p == nil { + return fmt.Errorf("params is required") + } + if p.UserID == "" && p.MemberID == "" { + return fmt.Errorf("user_id or member_id is required") + } + if p.MemberID != "" && p.TeamID == "" { + return fmt.Errorf("team_id is required when member_id is set") + } + if p.Redirect == "" { + return fmt.Errorf("redirect is required") + } + return nil +} diff --git a/openapi/otp/handler.go b/openapi/otp/handler.go new file mode 100644 index 00000000..9d88b603 --- /dev/null +++ b/openapi/otp/handler.go @@ -0,0 +1,214 @@ +package otp + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/user" + "github.com/yaoapp/yao/openapi/utils" +) + +// OTPCreateRequest is the JSON body for POST /otp/create. +type OTPCreateRequest struct { + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + Redirect string `json:"redirect" binding:"required"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds) + Consume *bool `json:"consume,omitempty"` // revoke code after login; nil means default (true) +} + +// OTPLoginRequest is the JSON body for POST /otp/login. +type OTPLoginRequest struct { + Code string `json:"code" binding:"required"` + Locale string `json:"locale,omitempty"` +} + +// Attach registers OTP HTTP routes on the given router group. +// The create endpoint requires authentication; login is public. +func Attach(group *gin.RouterGroup, auth types.OAuth) { + group.POST("/create", auth.Guard, GinOTPCreate) + group.POST("/login", GinOTPLogin) +} + +// GinOTPCreate handles POST /otp/create (protected). +// It forces team_id from the caller's identity and validates that the +// target member belongs to the same team. +func GinOTPCreate(c *gin.Context) { + authInfo := authorized.GetInfo(c) + if authInfo == nil || authInfo.TeamID == "" { + response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ + Code: "forbidden", + ErrorDescription: "team context is required", + }) + return + } + + var req OTPCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + if req.UserID == "" && req.MemberID == "" { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "user_id or member_id is required", + }) + return + } + + teamID := authInfo.TeamID + + // Validate that the target member/user belongs to the caller's team + if err := validateTeamMembership(teamID, req.UserID, req.MemberID); err != nil { + response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ + Code: "forbidden", + ErrorDescription: err.Error(), + }) + return + } + + consume := true + if req.Consume != nil { + consume = *req.Consume + } + + code, err := OTP.Create(&GenerateParams{ + TeamID: teamID, + MemberID: req.MemberID, + UserID: req.UserID, + ExpiresIn: req.ExpiresIn, + Redirect: req.Redirect, + Scope: req.Scope, + TokenExpiresIn: req.TokenExpiresIn, + Consume: consume, + }) + if err != nil { + response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{ + Code: "server_error", + ErrorDescription: err.Error(), + }) + return + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{"code": code}) +} + +// GinOTPLogin handles POST /otp/login (public). +// Smart login: checks existing session first, issues tokens only when needed. +func GinOTPLogin(c *gin.Context) { + var req OTPLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + payload, err := OTP.Verify(req.Code) + if err != nil { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "invalid_otp", + ErrorDescription: err.Error(), + }) + return + } + + var status string + + // Check if the caller already has a valid session + existingToken := oauth.OAuth.GetAccessToken(c) + if existingToken != "" { + if _, verifyErr := oauth.OAuth.VerifyToken(existingToken); verifyErr == nil { + status = "already_logged_in" + } + } + + // No valid session: perform OTP login and set cookies + if status == "" { + result, err := OTP.Login(req.Code, req.Locale) + if err != nil { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "otp_login_failed", + ErrorDescription: err.Error(), + }) + return + } + + sid := utils.GetSessionID(c) + if sid == "" { + sid = session.ID() + } + + loginResp := &user.LoginResponse{ + UserID: result.UserID, + Subject: result.Subject, + AccessToken: result.AccessToken, + IDToken: result.IDToken, + RefreshToken: result.RefreshToken, + ExpiresIn: result.ExpiresIn, + RefreshTokenExpiresIn: result.RefreshTokenExpiresIn, + TokenType: result.TokenType, + Scope: result.Scope, + Status: user.LoginStatusSuccess, + } + user.SendLoginCookies(c, loginResp, sid) + status = "success" + } + + // Consume OTP code if configured + if payload.Consume { + _ = OTP.Revoke(req.Code) + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "status": status, + "redirect": payload.Redirect, + }) +} + +// validateTeamMembership checks that the given user or member belongs to the specified team. +func validateTeamMembership(teamID, userID, memberID string) error { + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if memberID != "" { + member, err := userProvider.GetMemberByMemberID(ctx, memberID) + if err != nil { + return fmt.Errorf("member not found: %s", memberID) + } + memberTeam := utils.ToString(member["team_id"]) + if memberTeam != teamID { + return fmt.Errorf("member %s does not belong to team %s", memberID, teamID) + } + return nil + } + + if userID != "" { + _, err := userProvider.GetMember(ctx, teamID, userID) + if err != nil { + return fmt.Errorf("user %s is not a member of team %s", userID, teamID) + } + return nil + } + + return fmt.Errorf("user_id or member_id is required") +} diff --git a/openapi/otp/login.go b/openapi/otp/login.go new file mode 100644 index 00000000..bbd9cc6a --- /dev/null +++ b/openapi/otp/login.go @@ -0,0 +1,95 @@ +package otp + +import ( + "context" + "fmt" + "strings" + + "github.com/yaoapp/yao/openapi/oauth" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/user" + "github.com/yaoapp/yao/openapi/utils" +) + +// Login verifies an OTP code, resolves the user identity, issues tokens, +// and returns the result. It does NOT set HTTP cookies. +func (s *Service) Login(code string, locale string) (*LoginResult, error) { + payload, err := s.Verify(code) + if err != nil { + return nil, err + } + + userID, teamID, err := s.resolveIdentity(payload) + if err != nil { + return nil, fmt.Errorf("failed to resolve OTP identity: %w", err) + } + + loginCtx := &oauthtypes.LoginContext{ + Locale: locale, + AuthSource: "otp", + } + + opts := &user.LoginOptions{ + SkipRefreshToken: true, + TokenExpiresIn: payload.TokenExpiresIn, + } + if payload.Scope != "" { + opts.Scopes = strings.Fields(payload.Scope) + } + + loginResp, err := user.LoginWithOptions(userID, teamID, loginCtx, opts) + if err != nil { + return nil, fmt.Errorf("OTP login failed: %w", err) + } + + return &LoginResult{ + UserID: loginResp.UserID, + Subject: loginResp.Subject, + AccessToken: loginResp.AccessToken, + IDToken: loginResp.IDToken, + RefreshToken: loginResp.RefreshToken, + ExpiresIn: loginResp.ExpiresIn, + RefreshTokenExpiresIn: loginResp.RefreshTokenExpiresIn, + TokenType: loginResp.TokenType, + Scope: loginResp.Scope, + Redirect: payload.Redirect, + }, nil +} + +// resolveIdentity determines the user_id and team_id from the OTP payload. +// When member_id is present, it resolves the user_id from the member record. +func (s *Service) resolveIdentity(payload *Payload) (userID string, teamID string, err error) { + teamID = payload.TeamID + + if payload.UserID != "" { + return payload.UserID, teamID, nil + } + + if payload.MemberID == "" { + return "", "", fmt.Errorf("payload has neither user_id nor member_id") + } + + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return "", "", fmt.Errorf("failed to get user provider: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + member, err := userProvider.GetMemberByMemberID(ctx, payload.MemberID) + if err != nil { + return "", "", fmt.Errorf("failed to resolve member %s: %w", payload.MemberID, err) + } + + userID = utils.ToString(member["user_id"]) + if userID == "" { + return "", "", fmt.Errorf("member %s has no associated user_id", payload.MemberID) + } + + if teamID == "" { + teamID = utils.ToString(member["team_id"]) + } + + return userID, teamID, nil +} diff --git a/openapi/otp/otp.go b/openapi/otp/otp.go new file mode 100644 index 00000000..6fc3fafd --- /dev/null +++ b/openapi/otp/otp.go @@ -0,0 +1,81 @@ +package otp + +import ( + "fmt" + "time" + + "github.com/yaoapp/gou/store" +) + +// OTP is the global OTP service instance, initialized by NewService. +var OTP *Service + +const ( + defaultExpiresIn = 24 * 60 * 60 // 24 hours in seconds + storeKeyInfix = "oauth:otp:" + codeAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" + codeLength = 12 + maxCollisionRetry = 5 +) + +// Service manages OTP code lifecycle. +type Service struct { + store store.Store + prefix string // key prefix, e.g. "yao_:" +} + +// Payload is the data stored alongside an OTP code. +type Payload struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` + Consume bool `json:"consume"` +} + +// GenerateParams holds the input for creating an OTP code. +type GenerateParams struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` // seconds; 0 means default (24h) + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds); 0 means system default + Consume bool `json:"consume"` // revoke code after login; default true +} + +// LoginResult wraps user.LoginResponse with the OTP redirect path. +type LoginResult struct { + UserID string `json:"user_id,omitempty"` + Subject string `json:"subject,omitempty"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` + Redirect string `json:"redirect"` +} + +// NewService creates and registers a global OTP service. +func NewService(s store.Store, prefix string) *Service { + OTP = &Service{store: s, prefix: prefix} + return OTP +} + +// storeKey builds a namespaced store key for the given OTP code. +func (s *Service) storeKey(code string) string { + return fmt.Sprintf("%s%s%s", s.prefix, storeKeyInfix, code) +} + +// ttl returns the effective TTL as a time.Duration. +func ttl(expiresIn int) time.Duration { + if expiresIn <= 0 { + expiresIn = defaultExpiresIn + } + return time.Duration(expiresIn) * time.Second +} diff --git a/openapi/otp/process.go b/openapi/otp/process.go new file mode 100644 index 00000000..854e24a8 --- /dev/null +++ b/openapi/otp/process.go @@ -0,0 +1,92 @@ +package otp + +import ( + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" + "github.com/yaoapp/yao/openapi/utils" +) + +func init() { + process.RegisterGroup("otp", map[string]process.Handler{ + "create": processCreate, + "verify": processVerify, + "login": processLogin, + "revoke": processRevoke, + }) +} + +// processCreate handles otp.Create(params). +// args[0]: map with team_id, member_id, user_id, expires_in, redirect, scope, +// +// token_expires_in, consume. +func processCreate(p *process.Process) interface{} { + p.ValidateArgNums(1) + + raw := p.ArgsMap(0) + params := &GenerateParams{ + TeamID: utils.ToString(raw["team_id"]), + MemberID: utils.ToString(raw["member_id"]), + UserID: utils.ToString(raw["user_id"]), + Redirect: utils.ToString(raw["redirect"]), + Scope: utils.ToString(raw["scope"]), + Consume: true, + } + if v, ok := raw["expires_in"]; ok { + params.ExpiresIn = utils.ToInt(v) + } + if v, ok := raw["token_expires_in"]; ok { + params.TokenExpiresIn = utils.ToInt(v) + } + if v, ok := raw["consume"]; ok { + params.Consume = utils.ToBool(v) + } + + code, err := OTP.Create(params) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + return code +} + +// processVerify handles otp.Verify(code). +// args[0]: code string. +func processVerify(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + payload, err := OTP.Verify(code) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + return payload +} + +// processLogin handles otp.Login(code, locale?). +// args[0]: code string; args[1]: locale string (optional). +func processLogin(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + locale := "" + if p.NumOfArgs() > 1 { + locale = p.ArgsString(1) + } + + result, err := OTP.Login(code, locale) + if err != nil { + exception.New(err.Error(), 401).Throw() + } + return result +} + +// processRevoke handles otp.Revoke(code). +// args[0]: code string. +func processRevoke(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + if err := OTP.Revoke(code); err != nil { + exception.New(err.Error(), 500).Throw() + } + return nil +} diff --git a/openapi/otp/revoke.go b/openapi/otp/revoke.go new file mode 100644 index 00000000..d1d5d417 --- /dev/null +++ b/openapi/otp/revoke.go @@ -0,0 +1,12 @@ +package otp + +// Revoke removes an OTP code from the store immediately. +// It is silent when the code does not exist or has already expired. +func (s *Service) Revoke(code string) error { + if code == "" { + return nil + } + key := s.storeKey(code) + _ = s.store.Del(key) + return nil +} diff --git a/openapi/otp/verify.go b/openapi/otp/verify.go new file mode 100644 index 00000000..de115146 --- /dev/null +++ b/openapi/otp/verify.go @@ -0,0 +1,76 @@ +package otp + +import ( + "encoding/json" + "fmt" +) + +// Verify looks up an OTP code and returns its stored Payload. +// It does NOT consume the code — the code remains valid within its TTL. +func (s *Service) Verify(code string) (*Payload, error) { + if code == "" { + return nil, fmt.Errorf("code is required") + } + + key := s.storeKey(code) + val, ok := s.store.Get(key) + if !ok || val == nil { + return nil, fmt.Errorf("invalid or expired OTP code") + } + + return coercePayload(val) +} + +// coercePayload converts a store value into a *Payload. +// The store may return *Payload, map[string]interface{}, or raw JSON bytes. +func coercePayload(val interface{}) (*Payload, error) { + switch v := val.(type) { + case *Payload: + return v, nil + + case Payload: + return &v, nil + + case map[string]interface{}: + p := &Payload{Consume: true} + if s, ok := v["team_id"].(string); ok { + p.TeamID = s + } + if s, ok := v["member_id"].(string); ok { + p.MemberID = s + } + if s, ok := v["user_id"].(string); ok { + p.UserID = s + } + if s, ok := v["redirect"].(string); ok { + p.Redirect = s + } + if s, ok := v["scope"].(string); ok { + p.Scope = s + } + switch te := v["token_expires_in"].(type) { + case float64: + p.TokenExpiresIn = int(te) + case int: + p.TokenExpiresIn = te + case int64: + p.TokenExpiresIn = int(te) + } + if b, ok := v["consume"].(bool); ok { + p.Consume = b + } + return p, nil + + default: + // JSON fallback for serialised store backends + raw, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("unexpected OTP payload type: %T", val) + } + p := &Payload{Consume: true} + if err := json.Unmarshal(raw, p); err != nil { + return nil, fmt.Errorf("failed to decode OTP payload: %w", err) + } + return p, nil + } +} diff --git a/openapi/tests/otp/otp_test.go b/openapi/tests/otp/otp_test.go new file mode 100644 index 00000000..3bd6be9c --- /dev/null +++ b/openapi/tests/otp/otp_test.go @@ -0,0 +1,777 @@ +package otp_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/otp" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +type otpTestContext struct { + UserID string + TeamID string + MemberID string + Token string // access token with team context + ClientID string + Scope string +} + +// setupTestData creates a real user, team type, team, and member for OTP tests. +// Returns an otpTestContext and a cleanup function. +func setupTestData(t *testing.T, serverURL string) (*otpTestContext, func()) { + t.Helper() + provider := testutils.GetUserProvider(t) + ctx := context.Background() + + client := testutils.RegisterTestClient(t, "OTP Test Client", []string{"https://localhost/callback"}) + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + // Step 1: Create a team type + teamTypeID := fmt.Sprintf("otp_team_type_%d", time.Now().UnixNano()) + _, err := provider.CreateType(ctx, map[string]interface{}{ + "type_id": teamTypeID, + "name": "OTP Test Team Type", + "locale": "en-US", + "description": "Team type for OTP tests", + "is_active": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create team type") + + // Step 2: Create a team with role_id (required for ACL) + teamID := fmt.Sprintf("otp_test_team_%d", time.Now().UnixNano()) + _, err = provider.CreateTeam(ctx, map[string]interface{}{ + "team_id": teamID, + "name": "OTP Test Team", + "description": "Team for OTP integration tests", + "owner_id": tokenInfo.UserID, + "type_id": teamTypeID, + "role_id": "system:root", + "status": "active", + "is_verified": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create test team") + + // Step 3: Add user as team member with system:root role + memberID, err := provider.CreateMember(ctx, map[string]interface{}{ + "team_id": teamID, + "user_id": tokenInfo.UserID, + "member_type": "user", + "role_id": "system:root", + "is_owner": true, + "status": "active", + "joined_at": time.Now(), + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create team member") + + // Step 4: Get team details for extra claims + team, err := provider.GetTeamByMember(ctx, teamID, tokenInfo.UserID) + require.NoError(t, err) + + // Step 5: Create access token with team context + oauthService := oauth.OAuth + require.NotNil(t, oauthService, "OAuth service not initialized") + + subject, err := oauthService.Subject(client.ClientID, tokenInfo.UserID) + require.NoError(t, err) + + extraClaims := map[string]interface{}{ + "user_id": tokenInfo.UserID, + "team_id": teamID, + } + if tenantID, ok := team["tenant_id"].(string); ok && tenantID != "" { + extraClaims["tenant_id"] = tenantID + } + if ownerID, ok := team["owner_id"].(string); ok && ownerID != "" { + extraClaims["owner_id"] = ownerID + } + if typeID, ok := team["type_id"].(string); ok && typeID != "" { + extraClaims["type_id"] = typeID + } + + scope := "openid profile email system:root" + accessToken, err := oauthService.MakeAccessToken(client.ClientID, scope, subject, 3600, extraClaims) + require.NoError(t, err) + + cleanup := func() { + provider.DeleteTeam(ctx, teamID) + provider.DeleteType(ctx, teamTypeID) + testutils.CleanupTestClient(t, client.ClientID) + } + + return &otpTestContext{ + UserID: tokenInfo.UserID, + TeamID: teamID, + MemberID: memberID, + Token: accessToken, + ClientID: client.ClientID, + Scope: scope, + }, cleanup +} + +// ---------- POST /otp/login (public) ---------- + +func TestOTPLoginInvalidCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]string{"code": "nonexistent_code"}) + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "invalid_otp", result["error"]) +} + +func TestOTPLoginMissingCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]string{}) + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestOTPLoginInvalidJSON(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBufferString("not json")) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// ---------- POST /otp/create (protected) ---------- + +func TestOTPCreateUnauthorized(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": "someone", + "redirect": "/dashboard", + }) + resp, err := http.Post(serverURL+baseURL+"/otp/create", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestOTPCreateMissingRedirect(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestOTPCreateMissingUserAndMember(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "redirect": "/dashboard", + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// ---------- Full flow: create -> login ---------- + +func TestOTPCreateAndLogin(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + // Step 1: Create OTP code via API + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/test/dashboard", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createBody2, _ := io.ReadAll(createResp.Body) + t.Logf("Create response status: %d, body: %s", createResp.StatusCode, string(createBody2)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "OTP create should succeed, body: %s", string(createBody2)) + + var createResult map[string]interface{} + err = json.Unmarshal(createBody2, &createResult) + require.NoError(t, err) + + code, ok := createResult["code"].(string) + require.True(t, ok, "Response should contain a code string") + assert.Len(t, code, 12, "OTP code should be 12 characters long") + t.Logf("Created OTP code: %s", code) + + // Step 2: Login with the OTP code (public endpoint) + loginBody, _ := json.Marshal(map[string]string{ + "code": code, + "locale": "en-US", + }) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + + loginBody2, _ := io.ReadAll(loginResp.Body) + t.Logf("Login response status: %d, body: %s", loginResp.StatusCode, string(loginBody2)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login should succeed, body: %s", string(loginBody2)) + + var loginResult map[string]interface{} + err = json.Unmarshal(loginBody2, &loginResult) + require.NoError(t, err) + + assert.Equal(t, "success", loginResult["status"]) + assert.Equal(t, "/test/dashboard", loginResult["redirect"]) + + // Step 3: Verify the code is consumed (default Consume=true) + loginBody3, _ := json.Marshal(map[string]string{"code": code}) + loginResp3, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody3)) + require.NoError(t, err) + defer loginResp3.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, loginResp3.StatusCode, "Code should be consumed after first login") +} + +func TestOTPCreateAndLoginWithMemberID(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + // Create OTP with member_id instead of user_id + createBody, _ := json.Marshal(map[string]interface{}{ + "team_id": tc.TeamID, + "member_id": tc.MemberID, + "redirect": "/member-login-test", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + // Verify the stored payload has member_id + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, tc.MemberID, payload.MemberID) + assert.Equal(t, tc.TeamID, payload.TeamID) + assert.Equal(t, "", payload.UserID, "user_id should be empty when member_id is used") + + // Login with the OTP code — should resolve member_id to user_id + loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + + loginRaw, _ := io.ReadAll(loginResp.Body) + t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login with member_id should succeed, body: %s", string(loginRaw)) + + var loginResult map[string]interface{} + json.Unmarshal(loginRaw, &loginResult) + assert.Equal(t, "success", loginResult["status"]) + assert.Equal(t, "/member-login-test", loginResult["redirect"]) +} + +func TestOTPCreateWithConsumeDisabled(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + consumeFalse := false + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/reusable", + "consume": &consumeFalse, + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + t.Logf("Created reusable OTP code: %s", code) + + // First login + loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + // Second login should also work (Consume=false) + loginBody2, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2)) + require.NoError(t, err) + defer loginResp2.Body.Close() + + assert.Equal(t, http.StatusOK, loginResp2.StatusCode, "Reusable OTP code should allow multiple logins") +} + +func TestOTPCreateWithTokenExpiresIn(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/custom-ttl", + "token_expires_in": 600, + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, 600, payload.TokenExpiresIn) + assert.Equal(t, "/custom-ttl", payload.Redirect) + assert.True(t, payload.Consume) +} + +// ---------- Service-level tests (direct API) ---------- + +func TestOTPServiceCreateAndVerify(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "test_user_direct", + TeamID: "test_team_direct", + Redirect: "/direct-test", + Consume: true, + }) + require.NoError(t, err) + assert.Len(t, code, 12) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "test_user_direct", payload.UserID) + assert.Equal(t, "test_team_direct", payload.TeamID) + assert.Equal(t, "/direct-test", payload.Redirect) + assert.True(t, payload.Consume) + assert.Equal(t, 0, payload.TokenExpiresIn) +} + +func TestOTPServiceCreateAndVerifyWithMemberID(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + TeamID: "team_member_test", + MemberID: "member_12345", + Redirect: "/member-redirect", + Scope: "read:data", + TokenExpiresIn: 900, + Consume: false, + }) + require.NoError(t, err) + assert.Len(t, code, 12) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "", payload.UserID) + assert.Equal(t, "team_member_test", payload.TeamID) + assert.Equal(t, "member_12345", payload.MemberID) + assert.Equal(t, "/member-redirect", payload.Redirect) + assert.Equal(t, "read:data", payload.Scope) + assert.Equal(t, 900, payload.TokenExpiresIn) + assert.False(t, payload.Consume) +} + +func TestOTPServiceCreateStoresMapPayload(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + TeamID: "map_team", + MemberID: "map_member", + Redirect: "$dashboard/assistants", + Scope: "openid profile", + TokenExpiresIn: 600, + Consume: true, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "map_team", payload.TeamID) + assert.Equal(t, "map_member", payload.MemberID) + assert.Equal(t, "$dashboard/assistants", payload.Redirect) + assert.Equal(t, "openid profile", payload.Scope) + assert.Equal(t, 600, payload.TokenExpiresIn) + assert.True(t, payload.Consume) +} + +func TestOTPServiceVerifyEmptyCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + _, err := otp.OTP.Verify("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "code is required") +} + +func TestOTPServiceVerifyNonexistent(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + _, err := otp.OTP.Verify("doesnotexist1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid or expired") +} + +func TestOTPServiceRevoke(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "revoke_user", + Redirect: "/revoke-test", + Consume: true, + }) + require.NoError(t, err) + + _, err = otp.OTP.Verify(code) + require.NoError(t, err) + + err = otp.OTP.Revoke(code) + require.NoError(t, err) + + _, err = otp.OTP.Verify(code) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid or expired") +} + +func TestOTPServiceRevokeEmpty(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + err := otp.OTP.Revoke("") + assert.NoError(t, err, "Revoking empty code should be silent") +} + +func TestOTPServiceCreateValidation(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + tests := []struct { + name string + params *otp.GenerateParams + errMsg string + }{ + {"nil params", nil, "params is required"}, + {"missing user and member", &otp.GenerateParams{Redirect: "/test"}, "user_id or member_id is required"}, + {"missing redirect", &otp.GenerateParams{UserID: "u1"}, "redirect is required"}, + {"member_id without team_id", &otp.GenerateParams{MemberID: "m1", Redirect: "/test"}, "team_id is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := otp.OTP.Create(tt.params) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestOTPServiceCreateWithScope(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "scope_user", + TeamID: "scope_team", + Redirect: "/scoped", + Scope: "read:data write:data", + Consume: false, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "read:data write:data", payload.Scope) + assert.False(t, payload.Consume) +} + +func TestOTPServiceCreateWithCustomExpiry(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "expiry_user", + Redirect: "/custom-expiry", + ExpiresIn: 60, + TokenExpiresIn: 300, + Consume: true, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, 300, payload.TokenExpiresIn) +} + +// ---------- Cross-team validation ---------- + +func TestOTPCreateCrossTeamRejected(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": "user_not_in_team_" + fmt.Sprintf("%d", time.Now().UnixNano()), + "redirect": "/cross-team", + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "Should reject OTP creation for user not in team") +} + +// ---------- OTP Login sets cookies (no refresh token) ---------- + +func TestOTPLoginSetsAccessTokenCookieOnly(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/cookie-test", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + loginBody, _ := json.Marshal(map[string]string{"code": code}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + cookies := loginResp.Cookies() + hasAccessToken := false + hasRefreshToken := false + for _, c := range cookies { + t.Logf("Cookie: %s", c.Name) + if strings.HasSuffix(c.Name, "access_token") { + hasAccessToken = true + } + if strings.HasSuffix(c.Name, "refresh_token") { + hasRefreshToken = true + } + } + assert.True(t, hasAccessToken, "OTP login should set access_token cookie") + assert.False(t, hasRefreshToken, "OTP login should NOT set refresh_token cookie (SkipRefreshToken)") +} + +// ---------- Code uniqueness ---------- + +func TestOTPCodeUniqueness(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + codes := make(map[string]bool) + for i := 0; i < 50; i++ { + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: fmt.Sprintf("unique_user_%d", i), + Redirect: "/unique", + Consume: true, + }) + require.NoError(t, err) + assert.False(t, codes[code], "Duplicate OTP code generated: %s", code) + codes[code] = true + } + assert.Len(t, codes, 50, "All 50 codes should be unique") +} diff --git a/openapi/user/login.go b/openapi/user/login.go index b50d1ac6..fb951f8f 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -496,6 +496,115 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login return resp, nil } +// LoginWithOptions performs the same login flow as LoginByTeamID but allows +// overriding scopes via opts. When opts.Scopes is non-nil, those scopes are +// used instead of the user/client defaults. +func LoginWithOptions(userid string, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) { + if opts == nil { + return LoginByTeamID(userid, teamID, loginCtx) + } + + hasOverrides := opts.Scopes != nil || opts.TokenExpiresIn > 0 || opts.SkipRefreshToken + if !hasOverrides { + return LoginByTeamID(userid, teamID, loginCtx) + } + + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + userData, err := userProvider.GetUserWithScopes(ctx, userid) + if err != nil { + return nil, err + } + + // Resolve scopes: use opts.Scopes when provided, otherwise fall back to + // the same default resolution as LoginByTeamID (user scopes or client config). + scopes := opts.Scopes + if scopes == nil { + yaoClientConfig := GetYaoClientConfig() + scopes = yaoClientConfig.Scopes + if v, ok := userData["scopes"].([]string); ok { + scopes = v + } + } + + subject, err := oauth.OAuth.Subject(GetYaoClientConfig().ClientID, userid) + if err != nil { + log.Warn("Failed to store user fingerprint: %s", err.Error()) + } + + if teamID == "" || teamID == "personal" { + resp, err := issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: "", + Team: nil, + Member: nil, + User: userData, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + TokenExpiresIn: opts.TokenExpiresIn, + SkipRefreshToken: opts.SkipRefreshToken, + }) + if err != nil { + return nil, err + } + locale := "" + if loginCtx != nil { + locale = loginCtx.Locale + } + go prepareUserKBCollection(userid, "", locale) + return resp, nil + } + + team, err := userProvider.GetTeamByMember(ctx, teamID, userid) + if err != nil { + return nil, fmt.Errorf("access denied: you are not a member of this team") + } + + member, err := userProvider.GetMember(ctx, teamID, userid) + if err != nil { + log.Warn("Failed to get member profile: %s", err.Error()) + member = nil + } + + if loginCtx != nil { + err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx) + if err != nil { + log.Warn("Failed to update last login: %s", err.Error()) + } + } + + resp, err := issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: teamID, + Team: team, + Member: member, + User: userData, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + TokenExpiresIn: opts.TokenExpiresIn, + SkipRefreshToken: opts.SkipRefreshToken, + }) + if err != nil { + return nil, err + } + + locale := "" + if loginCtx != nil { + locale = loginCtx.Locale + } + go prepareUserKBCollection(userid, teamID, locale) + + return resp, nil +} + // issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token) func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) { yaoClientConfig := GetYaoClientConfig() @@ -571,6 +680,11 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse } } + // 4. Caller overrides (e.g. OTP login with custom token lifetime) + if params.TokenExpiresIn > 0 { + expiresIn = params.TokenExpiresIn + } + // Prepare OIDC user info oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User) oidcUserInfo.Sub = params.Subject @@ -717,15 +831,19 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse return nil, fmt.Errorf("failed to sign access token: %w", err) } - // Sign Refresh Token + // Sign Refresh Token (skip for temporary sessions like OTP) var refreshToken string - if len(extraClaims) > 0 { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims) + if params.SkipRefreshToken { + refreshTokenExpiresIn = 0 } else { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn) - } - if err != nil { - return nil, fmt.Errorf("failed to sign refresh token: %w", err) + if len(extraClaims) > 0 { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims) + } else { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn) + } + if err != nil { + return nil, fmt.Errorf("failed to sign refresh token: %w", err) + } } return &LoginResponse{ @@ -901,17 +1019,17 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st // Normal Access Token accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken) - 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 (cookie lifetime = refresh token lifetime) - response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires) - - // Send refresh token cookie - response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) + if loginResponse.RefreshToken != "" { + refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) + // 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. + refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) + response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires) + response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) + } else { + // No refresh token (e.g. OTP login): cookie expires with the access token + accessExpires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second) + response.SendAccessTokenCookieWithExpiry(c, accessToken, accessExpires) + } } diff --git a/openapi/user/types.go b/openapi/user/types.go index 623acad5..248ef269 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -265,17 +265,26 @@ type LoginSuccessResponse struct { // LoginContext is an alias for the oauth types LoginContext type LoginContext = oauthtypes.LoginContext +// LoginOptions provides optional overrides for the login flow. +type LoginOptions struct { + Scopes []string // When non-nil, overrides the default scope resolution + TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds) + SkipRefreshToken bool // When true, do not issue refresh_token (for OTP/temporary sessions) +} + // IssueTokensParams represents parameters for issueTokens function type IssueTokensParams struct { - UserID string // User ID - TeamID string // Team ID (empty for personal account) - Team map[string]interface{} // Team data (nil for personal account) - Member map[string]interface{} // Member profile data (nil for personal account or if not available) - User map[string]interface{} // User data - Subject string // Token subject - Scopes []string // Token scopes - LoginCtx *LoginContext // Login context (IP, user agent, etc.) - AuthSource string // Authentication source (password, google, github, etc.) + UserID string // User ID + TeamID string // Team ID (empty for personal account) + Team map[string]interface{} // Team data (nil for personal account) + Member map[string]interface{} // Member profile data (nil for personal account or if not available) + User map[string]interface{} // User data + Subject string // Token subject + Scopes []string // Token scopes + LoginCtx *LoginContext // Login context (IP, user agent, etc.) + AuthSource string // Authentication source (password, google, github, etc.) + TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds) + SkipRefreshToken bool // When true, skip refresh_token generation } // ==== Entry Verification Types ==== From 886c91372bca470344ab52774f226e415503c603 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 21:45:45 +0800 Subject: [PATCH 4/5] Disable /otp/create endpoint to prevent unauthorized OTP code generation; update documentation to reflect server-side creation process only. --- openapi/otp/README.md | 16 ++-------------- openapi/otp/handler.go | 6 ++++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/openapi/otp/README.md b/openapi/otp/README.md index 571be47e..d37b8b66 100644 --- a/openapi/otp/README.md +++ b/openapi/otp/README.md @@ -58,22 +58,10 @@ Deletes the code. Silent if the code does not exist. | Method | Path | Auth | Description | |---|---|---|---| -| `POST` | `/otp/create` | Bearer token (team context) | Create an OTP code | +| ~~`POST`~~ | ~~`/otp/create`~~ | ~~Bearer token~~ | **Disabled** — use `otp.Create` process instead | | `POST` | `/otp/login` | Public | Verify code, set session cookies | -### POST /otp/create - -Requires a valid access token with team context. The `team_id` is forced from the caller's identity. Validates that the target user/member belongs to the same team. - -**Request:** -```json -{"member_id": "M1", "redirect": "/agents/keeper/entry/xxx"} -``` - -**Response:** -```json -{"code": "abc123def456"} -``` +> **Note:** The `/otp/create` HTTP endpoint is intentionally disabled. Exposing it would allow any team member to generate OTP codes for other members, effectively logging in as them without credentials. OTP codes must be created server-side via the `otp.Create` process only. ### POST /otp/login diff --git a/openapi/otp/handler.go b/openapi/otp/handler.go index 9d88b603..61822b8b 100644 --- a/openapi/otp/handler.go +++ b/openapi/otp/handler.go @@ -33,9 +33,11 @@ type OTPLoginRequest struct { } // Attach registers OTP HTTP routes on the given router group. -// The create endpoint requires authentication; login is public. +// NOTE: /otp/create is disabled — OTP codes should only be created via +// server-side Process (otp.Create) to prevent team members from generating +// codes for other members and logging in as them. func Attach(group *gin.RouterGroup, auth types.OAuth) { - group.POST("/create", auth.Guard, GinOTPCreate) + // group.POST("/create", auth.Guard, GinOTPCreate) group.POST("/login", GinOTPLogin) } From 406750c665a5b117d317869d6eca20352aa14db4 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 21 Feb 2026 21:53:54 +0800 Subject: [PATCH 5/5] Refactor OTP tests to remove deprecated /otp/create endpoint; update tests to reflect server-side OTP generation and enhance login flow validation. --- openapi/tests/otp/otp_test.go | 277 ++++++---------------------------- 1 file changed, 45 insertions(+), 232 deletions(-) diff --git a/openapi/tests/otp/otp_test.go b/openapi/tests/otp/otp_test.go index 3bd6be9c..c23a71ba 100644 --- a/openapi/tests/otp/otp_test.go +++ b/openapi/tests/otp/otp_test.go @@ -182,85 +182,10 @@ func TestOTPLoginInvalidJSON(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } -// ---------- POST /otp/create (protected) ---------- +// ---------- POST /otp/create (disabled — HTTP endpoint removed for security) ---------- +// Validation tests now covered by TestOTPServiceCreateValidation. -func TestOTPCreateUnauthorized(t *testing.T) { - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - body, _ := json.Marshal(map[string]interface{}{ - "user_id": "someone", - "redirect": "/dashboard", - }) - resp, err := http.Post(serverURL+baseURL+"/otp/create", "application/json", bytes.NewBuffer(body)) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -} - -func TestOTPCreateMissingRedirect(t *testing.T) { - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - tc, cleanup := setupTestData(t, serverURL) - defer cleanup() - - body, _ := json.Marshal(map[string]interface{}{ - "user_id": tc.UserID, - }) - req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+tc.Token) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - rawBody, _ := io.ReadAll(resp.Body) - t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -} - -func TestOTPCreateMissingUserAndMember(t *testing.T) { - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - tc, cleanup := setupTestData(t, serverURL) - defer cleanup() - - body, _ := json.Marshal(map[string]interface{}{ - "redirect": "/dashboard", - }) - req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+tc.Token) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - rawBody, _ := io.ReadAll(resp.Body) - t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) -} - -// ---------- Full flow: create -> login ---------- +// ---------- Full flow: create (service) -> login (HTTP) ---------- func TestOTPCreateAndLogin(t *testing.T) { serverURL := testutils.Prepare(t) @@ -274,59 +199,36 @@ func TestOTPCreateAndLogin(t *testing.T) { tc, cleanup := setupTestData(t, serverURL) defer cleanup() - // Step 1: Create OTP code via API - createBody, _ := json.Marshal(map[string]interface{}{ - "user_id": tc.UserID, - "redirect": "/test/dashboard", + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: tc.UserID, + TeamID: tc.TeamID, + Redirect: "/test/dashboard", + Consume: true, }) - createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+tc.Token) - - createResp, err := http.DefaultClient.Do(createReq) require.NoError(t, err) - defer createResp.Body.Close() + assert.Len(t, code, 12) - createBody2, _ := io.ReadAll(createResp.Body) - t.Logf("Create response status: %d, body: %s", createResp.StatusCode, string(createBody2)) - require.Equal(t, http.StatusOK, createResp.StatusCode, "OTP create should succeed, body: %s", string(createBody2)) - - var createResult map[string]interface{} - err = json.Unmarshal(createBody2, &createResult) - require.NoError(t, err) - - code, ok := createResult["code"].(string) - require.True(t, ok, "Response should contain a code string") - assert.Len(t, code, 12, "OTP code should be 12 characters long") - t.Logf("Created OTP code: %s", code) - - // Step 2: Login with the OTP code (public endpoint) - loginBody, _ := json.Marshal(map[string]string{ - "code": code, - "locale": "en-US", - }) + // Login with the OTP code (public endpoint) + loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) require.NoError(t, err) defer loginResp.Body.Close() - loginBody2, _ := io.ReadAll(loginResp.Body) - t.Logf("Login response status: %d, body: %s", loginResp.StatusCode, string(loginBody2)) - require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login should succeed, body: %s", string(loginBody2)) + loginRaw, _ := io.ReadAll(loginResp.Body) + t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "body: %s", string(loginRaw)) var loginResult map[string]interface{} - err = json.Unmarshal(loginBody2, &loginResult) - require.NoError(t, err) - + json.Unmarshal(loginRaw, &loginResult) assert.Equal(t, "success", loginResult["status"]) assert.Equal(t, "/test/dashboard", loginResult["redirect"]) - // Step 3: Verify the code is consumed (default Consume=true) - loginBody3, _ := json.Marshal(map[string]string{"code": code}) - loginResp3, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody3)) + // Verify the code is consumed (default Consume=true) + loginBody2, _ := json.Marshal(map[string]string{"code": code}) + loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2)) require.NoError(t, err) - defer loginResp3.Body.Close() - - assert.Equal(t, http.StatusUnauthorized, loginResp3.StatusCode, "Code should be consumed after first login") + defer loginResp2.Body.Close() + assert.Equal(t, http.StatusUnauthorized, loginResp2.StatusCode, "Code should be consumed after first login") } func TestOTPCreateAndLoginWithMemberID(t *testing.T) { @@ -341,36 +243,20 @@ func TestOTPCreateAndLoginWithMemberID(t *testing.T) { tc, cleanup := setupTestData(t, serverURL) defer cleanup() - // Create OTP with member_id instead of user_id - createBody, _ := json.Marshal(map[string]interface{}{ - "team_id": tc.TeamID, - "member_id": tc.MemberID, - "redirect": "/member-login-test", + code, err := otp.OTP.Create(&otp.GenerateParams{ + TeamID: tc.TeamID, + MemberID: tc.MemberID, + Redirect: "/member-login-test", + Consume: true, }) - createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+tc.Token) - - createResp, err := http.DefaultClient.Do(createReq) require.NoError(t, err) - defer createResp.Body.Close() - createRaw, _ := io.ReadAll(createResp.Body) - t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) - require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) - - var createResult map[string]interface{} - json.Unmarshal(createRaw, &createResult) - code := createResult["code"].(string) - - // Verify the stored payload has member_id payload, err := otp.OTP.Verify(code) require.NoError(t, err) assert.Equal(t, tc.MemberID, payload.MemberID) assert.Equal(t, tc.TeamID, payload.TeamID) - assert.Equal(t, "", payload.UserID, "user_id should be empty when member_id is used") + assert.Equal(t, "", payload.UserID) - // Login with the OTP code — should resolve member_id to user_id loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) require.NoError(t, err) @@ -378,7 +264,7 @@ func TestOTPCreateAndLoginWithMemberID(t *testing.T) { loginRaw, _ := io.ReadAll(loginResp.Body) t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw)) - require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login with member_id should succeed, body: %s", string(loginRaw)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "body: %s", string(loginRaw)) var loginResult map[string]interface{} json.Unmarshal(loginRaw, &loginResult) @@ -398,28 +284,13 @@ func TestOTPCreateWithConsumeDisabled(t *testing.T) { tc, cleanup := setupTestData(t, serverURL) defer cleanup() - consumeFalse := false - createBody, _ := json.Marshal(map[string]interface{}{ - "user_id": tc.UserID, - "redirect": "/reusable", - "consume": &consumeFalse, + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: tc.UserID, + TeamID: tc.TeamID, + Redirect: "/reusable", + Consume: false, }) - createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+tc.Token) - - createResp, err := http.DefaultClient.Do(createReq) require.NoError(t, err) - defer createResp.Body.Close() - - createRaw, _ := io.ReadAll(createResp.Body) - t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) - require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) - - var createResult map[string]interface{} - json.Unmarshal(createRaw, &createResult) - code := createResult["code"].(string) - t.Logf("Created reusable OTP code: %s", code) // First login loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) @@ -433,42 +304,21 @@ func TestOTPCreateWithConsumeDisabled(t *testing.T) { loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2)) require.NoError(t, err) defer loginResp2.Body.Close() - assert.Equal(t, http.StatusOK, loginResp2.StatusCode, "Reusable OTP code should allow multiple logins") } func TestOTPCreateWithTokenExpiresIn(t *testing.T) { serverURL := testutils.Prepare(t) defer testutils.Clean() + _ = serverURL - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - tc, cleanup := setupTestData(t, serverURL) - defer cleanup() - - createBody, _ := json.Marshal(map[string]interface{}{ - "user_id": tc.UserID, - "redirect": "/custom-ttl", - "token_expires_in": 600, + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "token_ttl_user", + Redirect: "/custom-ttl", + TokenExpiresIn: 600, + Consume: true, }) - createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+tc.Token) - - createResp, err := http.DefaultClient.Do(createReq) require.NoError(t, err) - defer createResp.Body.Close() - - createRaw, _ := io.ReadAll(createResp.Body) - t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) - require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) - - var createResult map[string]interface{} - json.Unmarshal(createRaw, &createResult) - code := createResult["code"].(string) payload, err := otp.OTP.Verify(code) require.NoError(t, err) @@ -671,33 +521,8 @@ func TestOTPServiceCreateWithCustomExpiry(t *testing.T) { } // ---------- Cross-team validation ---------- - -func TestOTPCreateCrossTeamRejected(t *testing.T) { - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - tc, cleanup := setupTestData(t, serverURL) - defer cleanup() - - body, _ := json.Marshal(map[string]interface{}{ - "user_id": "user_not_in_team_" + fmt.Sprintf("%d", time.Now().UnixNano()), - "redirect": "/cross-team", - }) - req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+tc.Token) - - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusForbidden, resp.StatusCode, "Should reject OTP creation for user not in team") -} +// NOTE: Cross-team HTTP endpoint test removed — /otp/create is disabled. +// Server-side Process callers are trusted and should validate team membership themselves. // ---------- OTP Login sets cookies (no refresh token) ---------- @@ -713,25 +538,13 @@ func TestOTPLoginSetsAccessTokenCookieOnly(t *testing.T) { tc, cleanup := setupTestData(t, serverURL) defer cleanup() - createBody, _ := json.Marshal(map[string]interface{}{ - "user_id": tc.UserID, - "redirect": "/cookie-test", + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: tc.UserID, + TeamID: tc.TeamID, + Redirect: "/cookie-test", + Consume: true, }) - createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createReq.Header.Set("Authorization", "Bearer "+tc.Token) - - createResp, err := http.DefaultClient.Do(createReq) require.NoError(t, err) - defer createResp.Body.Close() - - createRaw, _ := io.ReadAll(createResp.Body) - t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) - require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) - - var createResult map[string]interface{} - json.Unmarshal(createRaw, &createResult) - code := createResult["code"].(string) loginBody, _ := json.Marshal(map[string]string{"code": code}) loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody))