diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go index 00509934..0e30899e 100644 --- a/openapi/oauth/signing.go +++ b/openapi/oauth/signing.go @@ -607,6 +607,28 @@ func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *t claims["yao:team"] = teamMap } } + // Add Yao member info if present (for team context) + if userdata.YaoMember != nil { + memberMap := make(map[string]interface{}) + if userdata.YaoMember.MemberID != "" { + memberMap["member_id"] = userdata.YaoMember.MemberID + } + if userdata.YaoMember.DisplayName != "" { + memberMap["display_name"] = userdata.YaoMember.DisplayName + } + if userdata.YaoMember.Bio != "" { + memberMap["bio"] = userdata.YaoMember.Bio + } + if userdata.YaoMember.Avatar != "" { + memberMap["avatar"] = userdata.YaoMember.Avatar + } + if userdata.YaoMember.Email != "" { + memberMap["email"] = userdata.YaoMember.Email + } + if len(memberMap) > 0 { + claims["yao:member"] = memberMap + } + } // Add Yao type info if present if userdata.YaoType != nil { typeMap := make(map[string]interface{}) diff --git a/openapi/oauth/types/oidc.go b/openapi/oauth/types/oidc.go index 126c636c..7a578922 100644 --- a/openapi/oauth/types/oidc.go +++ b/openapi/oauth/types/oidc.go @@ -157,6 +157,29 @@ func (user OIDCUserInfo) Map() map[string]interface{} { } } + // Add Yao member info if present and has content (for team context) + if user.YaoMember != nil { + memberMap := make(map[string]interface{}) + if user.YaoMember.MemberID != "" { + memberMap["member_id"] = user.YaoMember.MemberID + } + if user.YaoMember.DisplayName != "" { + memberMap["display_name"] = user.YaoMember.DisplayName + } + if user.YaoMember.Bio != "" { + memberMap["bio"] = user.YaoMember.Bio + } + if user.YaoMember.Avatar != "" { + memberMap["avatar"] = user.YaoMember.Avatar + } + if user.YaoMember.Email != "" { + memberMap["email"] = user.YaoMember.Email + } + if len(memberMap) > 0 { + result["yao:member"] = memberMap + } + } + // Include raw data if available // if user.Raw != nil { // // Merge raw data, but let structured fields take precedence @@ -327,6 +350,27 @@ func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo { userInfo.YaoType = typeInfo } + // Yao member info (nested object, for team context) + if memberData, ok := user["yao:member"].(map[string]interface{}); ok { + member := &OIDCMemberInfo{} + if memberID, ok := memberData["member_id"].(string); ok { + member.MemberID = memberID + } + if displayName, ok := memberData["display_name"].(string); ok { + member.DisplayName = displayName + } + if bio, ok := memberData["bio"].(string); ok { + member.Bio = bio + } + if avatar, ok := memberData["avatar"].(string); ok { + member.Avatar = avatar + } + if email, ok := memberData["email"].(string); ok { + member.Email = email + } + userInfo.YaoMember = member + } + return userInfo } diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index d97a87b5..e205c723 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -714,13 +714,14 @@ type OIDCUserInfo struct { Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address // Additional custom claims with namespace - YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID) - YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID - YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID - YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info - YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner - YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID - YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info + YaoUserID string `json:"yao:user_id,omitempty"` // Yao user ID (original user ID) + YaoTenantID string `json:"yao:tenant_id,omitempty"` // Yao tenant ID + YaoTeamID string `json:"yao:team_id,omitempty"` // Yao team ID + YaoTeam *OIDCTeamInfo `json:"yao:team,omitempty"` // Yao team info + YaoIsOwner *bool `json:"yao:is_owner,omitempty"` // Yao is owner + YaoTypeID string `json:"yao:type_id,omitempty"` // Yao user type ID + YaoType *OIDCTypeInfo `json:"yao:type,omitempty"` // Yao user type info + YaoMember *OIDCMemberInfo `json:"yao:member,omitempty"` // Yao member profile info (for team context) // Raw response for debugging and custom processing Raw map[string]interface{} `json:"raw,omitempty"` // Original provider response @@ -743,6 +744,15 @@ type OIDCTypeInfo struct { Locale string `json:"locale,omitempty"` // User type locale } +// OIDCMemberInfo represents team member profile information +type OIDCMemberInfo struct { + MemberID string `json:"member_id,omitempty"` // Member's unique identifier in team + DisplayName string `json:"display_name,omitempty"` // Member's display name in team + Bio string `json:"bio,omitempty"` // Member's bio in team + Avatar string `json:"avatar,omitempty"` // Member's avatar in team + Email string `json:"email,omitempty"` // Member's email in team +} + // OIDCAddress represents the OIDC address claim structure type OIDCAddress struct { Formatted string `json:"formatted,omitempty"` // Full mailing address diff --git a/openapi/user/login.go b/openapi/user/login.go index c6fa2565..71e41478 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -267,7 +267,16 @@ func LoginByUserID(userid string, loginCtx *LoginContext) (*LoginResponse, error } // Issue tokens without team context - return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx) + return issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: "", + Team: nil, + Member: nil, + User: user, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + }) } // LoginByTeamID is the handler for login by team ID (after team selection) @@ -301,7 +310,16 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login // Handle personal account (no team) if teamID == "" || teamID == "personal" { - return issueTokens(ctx, userid, "", nil, user, subject, scopes, loginCtx) + return issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: "", + Team: nil, + Member: nil, + User: user, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + }) } // Verify user is a member of the team and get team details @@ -310,6 +328,14 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login return nil, fmt.Errorf("access denied: you are not a member of this team") } + // Get member profile information for team context + member, err := userProvider.GetMember(ctx, teamID, userid) + if err != nil { + log.Warn("Failed to get member profile: %s", err.Error()) + // Continue without member profile if it fails + member = nil + } + // Update Last Login if loginCtx != nil { err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx) @@ -318,12 +344,21 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login } } - // Issue tokens with team context - return issueTokens(ctx, userid, teamID, team, user, subject, scopes, loginCtx) + // Issue tokens with team context and member profile + return issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: teamID, + Team: team, + Member: member, + User: user, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + }) } // issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token) -func issueTokens(ctx context.Context, userid string, teamID string, team map[string]interface{}, user map[string]interface{}, subject string, scopes []string, loginCtx *LoginContext) (*LoginResponse, error) { +func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) { yaoClientConfig := GetYaoClientConfig() // Determine token expiration times based on Remember Me setting @@ -333,7 +368,7 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str locale := "" entryConfig := GetEntryConfig(locale) - if loginCtx != nil && loginCtx.RememberMe { + 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 @@ -412,62 +447,83 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str } // Prepare OIDC user info - oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) - oidcUserInfo.Sub = subject - oidcUserInfo.YaoUserID = userid // Add original user ID + oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User) + oidcUserInfo.Sub = params.Subject + oidcUserInfo.YaoUserID = params.UserID // Add original user ID // Prepare extra claims for access token extraClaims := make(map[string]interface{}) // Add team context if available - if teamID != "" && team != nil { - extraClaims["team_id"] = teamID + if params.TeamID != "" && params.Team != nil { + extraClaims["team_id"] = params.TeamID // Add tenant_id if available from the team - if tenantID := toString(team["tenant_id"]); tenantID != "" { + if tenantID := toString(params.Team["tenant_id"]); tenantID != "" { extraClaims["tenant_id"] = tenantID oidcUserInfo.YaoTenantID = tenantID } // Add team info to OIDC user info - oidcUserInfo.YaoTeamID = teamID + oidcUserInfo.YaoTeamID = params.TeamID teamInfo := &oauthtypes.OIDCTeamInfo{} - if teamIDVal := toString(team["team_id"]); teamIDVal != "" { + if teamIDVal := toString(params.Team["team_id"]); teamIDVal != "" { teamInfo.TeamID = teamIDVal } - if logo := toString(team["logo"]); logo != "" { + if logo := toString(params.Team["logo"]); logo != "" { teamInfo.Logo = logo } - if name := toString(team["name"]); name != "" { + if name := toString(params.Team["name"]); name != "" { teamInfo.Name = name } - if description := toString(team["description"]); description != "" { + if description := toString(params.Team["description"]); description != "" { teamInfo.Description = description } // Add owner_id if available from the team (only check once) - if ownerID := toString(team["owner_id"]); ownerID != "" { + if ownerID := toString(params.Team["owner_id"]); ownerID != "" { extraClaims["owner_id"] = ownerID teamInfo.OwnerID = ownerID // Check if user is owner - if ownerID == userid { + if ownerID == params.UserID { isOwner := true oidcUserInfo.YaoIsOwner = &isOwner } } oidcUserInfo.YaoTeam = teamInfo + + // Add member profile information if available + if params.Member != nil { + memberInfo := &oauthtypes.OIDCMemberInfo{} + if memberID := toString(params.Member["member_id"]); memberID != "" { + memberInfo.MemberID = memberID + } + if displayName := toString(params.Member["display_name"]); displayName != "" { + memberInfo.DisplayName = displayName + } + if bio := toString(params.Member["bio"]); bio != "" { + memberInfo.Bio = bio + } + if avatar := toString(params.Member["avatar"]); avatar != "" { + memberInfo.Avatar = avatar + } + if email := toString(params.Member["email"]); email != "" { + memberInfo.Email = email + } + oidcUserInfo.YaoMember = memberInfo + } } // Add type information (use team type if in team context, otherwise use user type) var typeID string - if teamID != "" && team != nil { + if params.TeamID != "" && params.Team != nil { // Team context - use team's type - typeID = toString(team["type_id"]) + typeID = toString(params.Team["type_id"]) } else { // Personal context - use user's type - typeID = toString(user["type_id"]) + typeID = toString(params.User["type_id"]) } if typeID != "" { @@ -500,9 +556,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str var oidcToken string var err error if len(extraClaims) > 0 { - oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo, extraClaims) + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), expiresIn, oidcUserInfo, extraClaims) } else { - oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), expiresIn, oidcUserInfo) + oidcToken, err = oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), expiresIn, oidcUserInfo) } if err != nil { return nil, fmt.Errorf("failed to sign OIDC token: %w", err) @@ -511,9 +567,9 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str // Sign Access Token var accessToken string if len(extraClaims) > 0 { - accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn, extraClaims) + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, expiresIn, extraClaims) } else { - accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, expiresIn) + accessToken, err = oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, expiresIn) } if err != nil { return nil, fmt.Errorf("failed to sign access token: %w", err) @@ -522,25 +578,25 @@ func issueTokens(ctx context.Context, userid string, teamID string, team map[str // Sign Refresh Token var refreshToken string if len(extraClaims) > 0 { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, refreshTokenExpiresIn, extraClaims) + 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(scopes, " "), subject, refreshTokenExpiresIn) + 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{ - UserID: userid, - Subject: subject, + UserID: params.UserID, + Subject: params.Subject, AccessToken: accessToken, IDToken: oidcToken, RefreshToken: refreshToken, ExpiresIn: expiresIn, RefreshTokenExpiresIn: refreshTokenExpiresIn, TokenType: "Bearer", - MFAEnabled: toBool(user["mfa_enabled"]), - Scope: strings.Join(scopes, " "), + MFAEnabled: toBool(params.User["mfa_enabled"]), + Scope: strings.Join(params.Scopes, " "), Status: LoginStatusSuccess, }, nil } diff --git a/openapi/user/types.go b/openapi/user/types.go index f53f01ca..d34594c5 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -265,6 +265,18 @@ type LoginSuccessResponse struct { // LoginContext is an alias for the oauth types LoginContext type LoginContext = oauthtypes.LoginContext +// 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.) +} + // ==== Entry Verification Types ==== // EntryVerifyRequest represents the request to verify entry (login/register)