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.
This commit is contained in:
Max 2025-10-11 19:56:29 +08:00
parent aae3447575
commit d78ed77b9e
12 changed files with 327 additions and 25 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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 == "" {

View file

@ -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) {

View file

@ -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 != "" {

View file

@ -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)

View file

@ -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_"))

View file

@ -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)

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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