From aae34475758ebb5e27534d5dcf741906ff5609d4 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 11 Oct 2025 19:04:39 +0800 Subject: [PATCH 1/2] Refactor token claims structure for improved readability - Removed unnecessary blank lines in the TokenClaims, AuthorizedInfo, and JWTClaims structs to enhance code clarity and maintainability. - Ensured consistent formatting across the claims structures for better organization. --- openapi/oauth/types/types.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index 7be73de1..161817ac 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -572,11 +572,11 @@ type TokenClaims struct { Issuer string `json:"iss,omitempty"` // Token issuer Audience []string `json:"aud,omitempty"` // Token audience JTI string `json:"jti,omitempty"` // JWT ID (for JWT tokens) - + // Extended claims for multi-tenancy and team support TeamID string `json:"team_id,omitempty"` // Team identifier TenantID string `json:"tenant_id,omitempty"` // Tenant identifier - + // Extra claims for flexibility Extra map[string]interface{} `json:"-"` // Additional custom claims (not serialized directly) } @@ -588,7 +588,7 @@ type AuthorizedInfo struct { Scope string `json:"scope,omitempty"` // Access scope SessionID string `json:"session_id,omitempty"` // Session ID UserID string `json:"user_id,omitempty"` // User ID - + // Extended fields for multi-tenancy and team support TeamID string `json:"team_id,omitempty"` // Team identifier TenantID string `json:"tenant_id,omitempty"` // Tenant identifier @@ -600,7 +600,7 @@ type JWTClaims struct { ClientID string `json:"client_id"` // OAuth client ID Scope string `json:"scope,omitempty"` // Access scope TokenType string `json:"token_type"` // Token type - + // Extended claims for multi-tenancy and team support TeamID string `json:"team_id,omitempty"` // Team identifier TenantID string `json:"tenant_id,omitempty"` // Tenant identifier From d78ed77b9ed73e10d1afea245035ee0ac1eda8ca Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 11 Oct 2025 19:56:29 +0800 Subject: [PATCH 2/2] Enhance token generation and storage to support optional extra claims - Updated methods related to refresh token generation and storage to accept optional extra claims, allowing for additional metadata such as team_id and tenant_id. - Refactored tests to utilize the new method signatures, ensuring comprehensive coverage of the updated functionality. - Introduced a new endpoint for team selection that issues tokens with team-specific claims, improving user experience in multi-team scenarios. --- openapi/oauth/core.go | 6 +- openapi/oauth/core_test.go | 12 +- openapi/oauth/guard.go | 21 +++ openapi/oauth/providers/user/team.go | 50 +++++++ openapi/oauth/signing.go | 9 +- openapi/oauth/token.go | 23 ++- openapi/oauth/token_test.go | 2 +- openapi/oauth/types/interfaces.go | 1 + openapi/user/login.go | 16 ++- openapi/user/team.go | 204 +++++++++++++++++++++++++++ openapi/user/types.go | 5 + openapi/user/user.go | 3 + 12 files changed, 327 insertions(+), 25 deletions(-) diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index 2ad7b656..045ad4d2 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -347,7 +347,7 @@ func (s *Service) RotateRefreshToken(ctx context.Context, oldToken string, reque } } - newRefreshToken, err := s.generateRefreshToken(clientID, finalScope, originalSubject) + newRefreshToken, err := s.generateRefreshToken(clientID, finalScope, originalSubject, 0, nil) if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, @@ -444,7 +444,7 @@ func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *type // Generate refresh token if supported if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) { - refreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject) + refreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject, 0, nil) if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, @@ -523,7 +523,7 @@ func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.Cli // Include refresh token if rotation is enabled if s.config.Features.RefreshTokenRotationEnabled { - newRefreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject) + newRefreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject, 0, nil) if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, diff --git a/openapi/oauth/core_test.go b/openapi/oauth/core_test.go index 4d8c7e61..1fbe69c6 100644 --- a/openapi/oauth/core_test.go +++ b/openapi/oauth/core_test.go @@ -372,7 +372,7 @@ func TestRefreshToken(t *testing.T) { subject := testUsers[0].UserID // Store refresh token with scope using storeRefreshTokenWithScope - err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject, 0, nil) assert.NoError(t, err) response, err := service.RefreshToken(ctx, refreshToken, "openid profile") @@ -391,7 +391,7 @@ func TestRefreshToken(t *testing.T) { subject := testUsers[0].UserID // Store refresh token with scope using storeRefreshTokenWithScope - err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject, 0, nil) assert.NoError(t, err) // Ensure rotation is enabled @@ -443,7 +443,7 @@ func TestRefreshToken(t *testing.T) { subject := testUsers[0].UserID // Store refresh token with limited scope - err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(refreshToken, clientID, originalScope, subject, 0, nil) assert.NoError(t, err) // Try to request scope that exceeds the original scope @@ -490,7 +490,7 @@ func TestRotateRefreshToken(t *testing.T) { subject := testUsers[0].UserID // Store old refresh token with scope using storeRefreshTokenWithScope - err := service.storeRefreshTokenWithScope(oldToken, clientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(oldToken, clientID, originalScope, subject, 0, nil) assert.NoError(t, err) // Ensure rotation is enabled @@ -651,7 +651,7 @@ func TestHandleRefreshTokenGrant(t *testing.T) { subject := testUsers[0].UserID // Store refresh token with scope using storeRefreshTokenWithScope - err := service.storeRefreshTokenWithScope(refreshToken, client.ClientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(refreshToken, client.ClientID, originalScope, subject, 0, nil) assert.NoError(t, err) // Ensure rotation is enabled @@ -686,7 +686,7 @@ func TestHandleRefreshTokenGrant(t *testing.T) { subject := testUsers[0].UserID // Store refresh token with scope using storeRefreshTokenWithScope - err := service.storeRefreshTokenWithScope(refreshToken, client.ClientID, originalScope, subject) + err := service.storeRefreshTokenWithScope(refreshToken, client.ClientID, originalScope, subject, 0, nil) assert.NoError(t, err) token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken) diff --git a/openapi/oauth/guard.go b/openapi/oauth/guard.go index 65aa6251..4e559d8b 100644 --- a/openapi/oauth/guard.go +++ b/openapi/oauth/guard.go @@ -58,6 +58,14 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo { info.Scope = scope.(string) } + if teamID, ok := c.Get("__team_id"); ok { + info.TeamID = teamID.(string) + } + + if tenantID, ok := c.Get("__tenant_id"); ok { + info.TenantID = tenantID.(string) + } + return info } @@ -80,6 +88,14 @@ func (s *Service) setAuthorizedInfo(c *gin.Context, claims *types.TokenClaims) { c.Set("__subject", claims.Subject) c.Set("__scope", claims.Scope) c.Set("__client_id", claims.ClientID) + + // Set team_id and tenant_id in context if available + if claims.TeamID != "" { + c.Set("__team_id", claims.TeamID) + } + if claims.TenantID != "" { + c.Set("__tenant_id", claims.TenantID) + } } func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) { @@ -113,6 +129,11 @@ func (s *Service) getAccessToken(c *gin.Context) string { return strings.TrimPrefix(token, "Bearer ") } +// GetAccessToken gets the access token from the request (public method) +func (s *Service) GetAccessToken(c *gin.Context) string { + return s.getAccessToken(c) +} + func (s *Service) getRefreshToken(c *gin.Context) string { token := c.GetHeader("Authorization") if token == "" { diff --git a/openapi/oauth/providers/user/team.go b/openapi/oauth/providers/user/team.go index d43ca1c9..7b91ed49 100644 --- a/openapi/oauth/providers/user/team.go +++ b/openapi/oauth/providers/user/team.go @@ -316,6 +316,56 @@ func (u *DefaultUser) GetTeamsByMember(ctx context.Context, memberID string) ([] return teams, nil } +// GetTeamByMember retrieves a specific team by team_id and member_id, verifying membership +// Returns the team with role information if the user is a member, or error if not +func (u *DefaultUser) GetTeamByMember(ctx context.Context, teamID string, memberID string) (maps.MapStrAny, error) { + // First, verify the user is a member of this team + memberParam := model.QueryParam{ + Select: []interface{}{"team_id", "user_id", "member_type", "role_id"}, + Wheres: []model.QueryWhere{ + {Column: "team_id", Value: teamID}, + {Column: "user_id", Value: memberID}, + {Column: "member_type", Value: "user"}, + {Column: "status", Value: "active"}, + }, + Limit: 1, + } + + memberModel := model.Select(u.memberModel) + members, err := memberModel.Get(memberParam) + if err != nil { + return nil, fmt.Errorf("failed to verify team membership: %w", err) + } + + if len(members) == 0 { + return nil, fmt.Errorf("user is not a member of the team") + } + + // Get role_id from member record + roleID := "" + if role, ok := members[0]["role_id"]; ok && role != nil { + roleID = fmt.Sprintf("%v", role) + } + + // Get team details + teamData, err := u.GetTeamDetail(ctx, teamID) + if err != nil { + return nil, fmt.Errorf("failed to get team details: %w", err) + } + + // Add role_id to team data + teamData["role_id"] = roleID + + // Check if user is the owner + ownerID := "" + if owner, ok := teamData["owner_id"]; ok && owner != nil { + ownerID = fmt.Sprintf("%v", owner) + } + teamData["is_owner"] = (ownerID == memberID) + + return teamData, nil +} + // CountTeamsByMember returns total count of teams by member_id func (u *DefaultUser) CountTeamsByMember(ctx context.Context, memberID string) (int64, error) { diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go index ec9d60f4..fb3da8d6 100644 --- a/openapi/oauth/signing.go +++ b/openapi/oauth/signing.go @@ -464,7 +464,7 @@ func (s *Service) VerifyToken(token string) (*types.TokenClaims, error) { } // SignIDToken signs an ID token with specific parameters and stores it -func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *types.OIDCUserInfo) (string, error) { +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 { return "", fmt.Errorf("signing certificates not initialized") } @@ -499,6 +499,13 @@ func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *t "jti": generateJTI(), } + // Add extra claims if provided (e.g., team_id, tenant_id) + if len(extraClaims) > 0 { + for key, value := range extraClaims[0] { + claims[key] = value + } + } + // Add user information from userdata // Add standard OIDC user claims if they exist if userdata.Name != "" { diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index 388fcca5..04a99c7c 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -258,8 +258,12 @@ func (s *Service) MakeAccessToken(clientID, scope, subject string, expiresIn int } // MakeRefreshToken generates a new refresh token with specific parameters and stores it -func (s *Service) MakeRefreshToken(clientID, scope, subject string, expiresIn ...int) (string, error) { - return s.generateRefreshToken(clientID, scope, subject, expiresIn...) +func (s *Service) MakeRefreshToken(clientID, scope, subject string, expiresIn int, extraClaims ...map[string]interface{}) (string, error) { + var claims map[string]interface{} + if len(extraClaims) > 0 { + claims = extraClaims[0] + } + return s.generateRefreshToken(clientID, scope, subject, expiresIn, claims) } // Subject converts a userID to a subject using NanoID fingerprint @@ -433,14 +437,14 @@ func (s *Service) revokeAccessToken(accessToken string) error { } // generateRefreshToken generates and stores a new refresh token with scope and subject -func (s *Service) generateRefreshToken(clientID, scope, subject string, expiresIn ...int) (string, error) { +func (s *Service) generateRefreshToken(clientID, scope, subject string, expiresIn int, extraClaims map[string]interface{}) (string, error) { refreshToken, err := s.generateToken("rfk", clientID) if err != nil { return "", err } // Store refresh token with metadata - err = s.storeRefreshTokenWithScope(refreshToken, clientID, scope, subject, expiresIn...) + err = s.storeRefreshTokenWithScope(refreshToken, clientID, scope, subject, expiresIn, extraClaims) if err != nil { return "", err } @@ -547,7 +551,7 @@ func (s *Service) storeRefreshToken(refreshToken, clientID string) error { } // storeRefreshTokenWithScope stores refresh token with metadata including scope and subject -func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subject string, expiresIn ...int) error { +func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subject string, expiresIn int, extraClaims map[string]interface{}) error { tokenData := map[string]interface{}{ "client_id": clientID, "scope": scope, @@ -556,9 +560,14 @@ func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subj "issued_at": time.Now().Unix(), } + // Add extra claims if provided (e.g., team_id, tenant_id) + for key, value := range extraClaims { + tokenData[key] = value + } + expires := s.config.Token.RefreshTokenLifetime - if len(expiresIn) > 0 && expiresIn[0] > 0 { - expires = time.Duration(expiresIn[0]) * time.Second + if expiresIn > 0 { + expires = time.Duration(expiresIn) * time.Second } return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, expires) diff --git a/openapi/oauth/token_test.go b/openapi/oauth/token_test.go index 18e95fca..7e75d8b2 100644 --- a/openapi/oauth/token_test.go +++ b/openapi/oauth/token_test.go @@ -573,7 +573,7 @@ func TestTokenGeneration(t *testing.T) { t.Run("generate refresh token", func(t *testing.T) { // Updated to use new generateRefreshToken signature with scope and subject - token, err := service.generateRefreshToken(clientID, "openid profile", testUsers[0].UserID) + token, err := service.generateRefreshToken(clientID, "openid profile", testUsers[0].UserID, 0, nil) assert.NoError(t, err) assert.NotEmpty(t, token) assert.True(t, strings.HasPrefix(token, "rfk_")) diff --git a/openapi/oauth/types/interfaces.go b/openapi/oauth/types/interfaces.go index adc2ba8d..176dd887 100644 --- a/openapi/oauth/types/interfaces.go +++ b/openapi/oauth/types/interfaces.go @@ -267,6 +267,7 @@ type UserProvider interface { // Team Query Methods GetTeamsByOwner(ctx context.Context, ownerID string) ([]maps.MapStr, error) GetTeamsByMember(ctx context.Context, memberID string) ([]maps.MapStr, error) + GetTeamByMember(ctx context.Context, teamID string, memberID string) (maps.MapStrAny, error) GetTeamsByStatus(ctx context.Context, status string) ([]maps.MapStr, error) CountTeamsByMember(ctx context.Context, memberID string) (int64, error) diff --git a/openapi/user/login.go b/openapi/user/login.go index 4b303378..17916285 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -265,15 +265,17 @@ func generateSessionID() string { } // SendLoginCookies sends all necessary cookies for a successful login -// This includes access token, refresh token, and session ID cookies with appropriate security settings +// 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 - expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second) - options := response.NewSecureCookieOptions(). - WithExpires(expires). - WithSameSite("Strict") - response.SendSecureCookieWithOptions(c, "session_id", sessionID, options) + // Send session ID cookie only if sessionID is provided + if sessionID != "" { + expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second) + options := response.NewSecureCookieOptions(). + WithExpires(expires). + WithSameSite("Strict") + response.SendSecureCookieWithOptions(c, "session_id", sessionID, options) + } // MFA Temporary Access Token if loginResponse.Status == LoginStatusMFA { diff --git a/openapi/user/team.go b/openapi/user/team.go index 00cf1f48..efb9a1c8 100644 --- a/openapi/user/team.go +++ b/openapi/user/team.go @@ -15,6 +15,7 @@ import ( "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/providers/user" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" ) @@ -308,6 +309,209 @@ func GinTeamUpdate(c *gin.Context) { response.RespondWithSuccess(c, http.StatusOK, team) } +// GinTeamSelection handles POST /teams/select - Select a team and issue tokens with team_id +func GinTeamSelection(c *gin.Context) { + // Get authorized user info + authInfo := oauth.GetAuthorizedInfo(c) + if authInfo == nil || authInfo.UserID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidClient.Code, + ErrorDescription: "User not authenticated", + } + response.RespondWithError(c, response.StatusUnauthorized, errorResp) + return + } + + // Verify the current token has team_selection scope + if authInfo.Scope != ScopeTeamSelection { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Invalid scope: team_selection scope required", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Parse request body + var req TeamSelectionRequest + if err := c.ShouldBindJSON(&req); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid request body: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + ctx := c.Request.Context() + + // Handle personal account selection (no team_id or "personal") + var extraClaims map[string]interface{} + var selectedTeam map[string]interface{} + + if req.TeamID == "" || req.TeamID == "personal" { + // Personal account - no team_id in token + extraClaims = nil + } else { + // Team account - verify membership and add team_id to token + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Verify user is a member of the team and get team details in one call + selectedTeam, err = provider.GetTeamByMember(ctx, req.TeamID, authInfo.UserID) + if err != nil { + log.Error("Failed to verify team membership: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Access denied: you are not a member of this team", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Prepare extra claims with team_id and tenant_id (if available) + extraClaims = map[string]interface{}{ + "team_id": req.TeamID, + } + + // Add tenant_id if available from the team + if tenantID := toString(selectedTeam["tenant_id"]); tenantID != "" { + extraClaims["tenant_id"] = tenantID + } + } + + // Get user provider and user data + provider, err := getUserProvider() + if err != nil { + log.Error("Failed to get user provider: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to initialize user provider", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get user data with scopes + user, err := provider.GetUserWithScopes(ctx, authInfo.UserID) + if err != nil { + log.Error("Failed to get user: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to retrieve user information", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Get Yao client config + yaoClientConfig := GetYaoClientConfig() + var scopes []string = yaoClientConfig.Scopes + if v, ok := user["scopes"].([]string); ok { + scopes = v + } + + // Get subject from auth info (it should be already stored) + subject := authInfo.Subject + if subject == "" { + // Fallback: create new subject if not available + subject, err = oauth.OAuth.Subject(yaoClientConfig.ClientID, authInfo.UserID) + if err != nil { + log.Warn("Failed to get subject: %s", err.Error()) + } + } + + // Sign OIDC Token (with or without team_id based on extraClaims) + oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) + oidcUserInfo.Sub = subject + var oidcToken string + if extraClaims != nil { + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo, extraClaims) + } else { + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo) + } + if err != nil { + log.Error("Failed to sign OIDC token: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to sign OIDC token", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Sign Access Token (with or without team_id based on extraClaims) + var accessToken string + if extraClaims != nil { + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn, extraClaims) + } else { + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn) + } + if err != nil { + log.Error("Failed to sign access token: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to sign access token", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Sign Refresh Token (with or without team_id based on extraClaims) + var refreshToken string + if extraClaims != nil { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn, extraClaims) + } else { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn) + } + if err != nil { + log.Error("Failed to sign refresh token: %v", err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to sign refresh token", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Revoke the current temporary token (read from header) + currentToken := oauth.OAuth.GetAccessToken(c) + if currentToken != "" { + if err := oauth.OAuth.Revoke(ctx, currentToken, "access_token"); err != nil { + // Log the error but don't fail the request + log.Warn("Failed to revoke temporary token: %v", err) + } + } + + // Prepare login response + loginResponse := &LoginResponse{ + UserID: authInfo.UserID, + Subject: subject, + AccessToken: accessToken, + IDToken: oidcToken, + RefreshToken: refreshToken, + ExpiresIn: yaoClientConfig.ExpiresIn, + RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn, + TokenType: "Bearer", + Scope: strings.Join(scopes, " "), + Status: LoginStatusSuccess, + } + + // Send secure cookies (access token, refresh token, and session ID) + SendLoginCookies(c, loginResponse, "") + + // Return the new tokens in response body + response.RespondWithSuccess(c, http.StatusOK, loginResponse) +} + // GinTeamDelete handles DELETE /teams/:id - Delete user team func GinTeamDelete(c *gin.Context) { // Get authorized user info diff --git a/openapi/user/types.go b/openapi/user/types.go index 45f23be6..97490895 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -282,6 +282,11 @@ type UpdateTeamRequest struct { Settings *TeamSettings `json:"settings,omitempty"` } +// TeamSelectionRequest represents the request to select a team +type TeamSelectionRequest struct { + TeamID string `json:"team_id" binding:"required"` +} + // ==== Member API Types ==== // MemberResponse represents a team member in API responses diff --git a/openapi/user/user.go b/openapi/user/user.go index 4440f3ac..89db6c6b 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -68,6 +68,9 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) { // Team Configuration team.GET("/config", GinTeamConfig) // Get team configuration (requires authentication) + // Team Selection + team.POST("/select", GinTeamSelection) // POST /teams/select - Select a team and issue tokens with team_id (requires authentication) + // Team CRUD - Standard REST endpoints team.GET("/", GinTeamList) // GET /teams - List user teams team.POST("/", GinTeamCreate) // POST /teams - Create new team