Merge pull request #1189 from trheyi/main
Enhance token generation and storage to support optional extra claims
This commit is contained in:
commit
6a69f486e5
13 changed files with 331 additions and 29 deletions
|
|
@ -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 {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorServerError,
|
Code: types.ErrorServerError,
|
||||||
|
|
@ -444,7 +444,7 @@ func (s *Service) handleAuthorizationCodeGrant(ctx context.Context, client *type
|
||||||
|
|
||||||
// Generate refresh token if supported
|
// Generate refresh token if supported
|
||||||
if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) {
|
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 {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorServerError,
|
Code: types.ErrorServerError,
|
||||||
|
|
@ -523,7 +523,7 @@ func (s *Service) handleRefreshTokenGrant(ctx context.Context, client *types.Cli
|
||||||
|
|
||||||
// Include refresh token if rotation is enabled
|
// Include refresh token if rotation is enabled
|
||||||
if s.config.Features.RefreshTokenRotationEnabled {
|
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 {
|
if err != nil {
|
||||||
return nil, &types.ErrorResponse{
|
return nil, &types.ErrorResponse{
|
||||||
Code: types.ErrorServerError,
|
Code: types.ErrorServerError,
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,7 @@ func TestRefreshToken(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store refresh token with scope using storeRefreshTokenWithScope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
response, err := service.RefreshToken(ctx, refreshToken, "openid profile")
|
response, err := service.RefreshToken(ctx, refreshToken, "openid profile")
|
||||||
|
|
@ -391,7 +391,7 @@ func TestRefreshToken(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store refresh token with scope using storeRefreshTokenWithScope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
|
|
@ -443,7 +443,7 @@ func TestRefreshToken(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store refresh token with limited scope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Try to request scope that exceeds the original scope
|
// Try to request scope that exceeds the original scope
|
||||||
|
|
@ -490,7 +490,7 @@ func TestRotateRefreshToken(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store old refresh token with scope using storeRefreshTokenWithScope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
|
|
@ -651,7 +651,7 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store refresh token with scope using storeRefreshTokenWithScope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Ensure rotation is enabled
|
// Ensure rotation is enabled
|
||||||
|
|
@ -686,7 +686,7 @@ func TestHandleRefreshTokenGrant(t *testing.T) {
|
||||||
subject := testUsers[0].UserID
|
subject := testUsers[0].UserID
|
||||||
|
|
||||||
// Store refresh token with scope using storeRefreshTokenWithScope
|
// 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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken)
|
token, err := service.handleRefreshTokenGrant(ctx, client, refreshToken)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
|
||||||
info.Scope = scope.(string)
|
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
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +88,14 @@ func (s *Service) setAuthorizedInfo(c *gin.Context, claims *types.TokenClaims) {
|
||||||
c.Set("__subject", claims.Subject)
|
c.Set("__subject", claims.Subject)
|
||||||
c.Set("__scope", claims.Scope)
|
c.Set("__scope", claims.Scope)
|
||||||
c.Set("__client_id", claims.ClientID)
|
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) {
|
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 ")
|
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 {
|
func (s *Service) getRefreshToken(c *gin.Context) string {
|
||||||
token := c.GetHeader("Authorization")
|
token := c.GetHeader("Authorization")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,56 @@ func (u *DefaultUser) GetTeamsByMember(ctx context.Context, memberID string) ([]
|
||||||
return teams, nil
|
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
|
// CountTeamsByMember returns total count of teams by member_id
|
||||||
func (u *DefaultUser) CountTeamsByMember(ctx context.Context, memberID string) (int64, error) {
|
func (u *DefaultUser) CountTeamsByMember(ctx context.Context, memberID string) (int64, error) {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,7 @@ func (s *Service) VerifyToken(token string) (*types.TokenClaims, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignIDToken signs an ID token with specific parameters and stores it
|
// 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 {
|
if s.signingCerts == nil || s.signingCerts.SigningKey == nil {
|
||||||
return "", fmt.Errorf("signing certificates not initialized")
|
return "", fmt.Errorf("signing certificates not initialized")
|
||||||
}
|
}
|
||||||
|
|
@ -499,6 +499,13 @@ func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *t
|
||||||
"jti": generateJTI(),
|
"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 user information from userdata
|
||||||
// Add standard OIDC user claims if they exist
|
// Add standard OIDC user claims if they exist
|
||||||
if userdata.Name != "" {
|
if userdata.Name != "" {
|
||||||
|
|
|
||||||
|
|
@ -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
|
// MakeRefreshToken generates a new refresh token with specific parameters and stores it
|
||||||
func (s *Service) MakeRefreshToken(clientID, scope, subject string, expiresIn ...int) (string, error) {
|
func (s *Service) MakeRefreshToken(clientID, scope, subject string, expiresIn int, extraClaims ...map[string]interface{}) (string, error) {
|
||||||
return s.generateRefreshToken(clientID, scope, subject, expiresIn...)
|
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
|
// 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
|
// 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)
|
refreshToken, err := s.generateToken("rfk", clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store refresh token with metadata
|
// 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -547,7 +551,7 @@ func (s *Service) storeRefreshToken(refreshToken, clientID string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeRefreshTokenWithScope stores refresh token with metadata including scope and subject
|
// 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{}{
|
tokenData := map[string]interface{}{
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
|
|
@ -556,9 +560,14 @@ func (s *Service) storeRefreshTokenWithScope(refreshToken, clientID, scope, subj
|
||||||
"issued_at": time.Now().Unix(),
|
"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
|
expires := s.config.Token.RefreshTokenLifetime
|
||||||
if len(expiresIn) > 0 && expiresIn[0] > 0 {
|
if expiresIn > 0 {
|
||||||
expires = time.Duration(expiresIn[0]) * time.Second
|
expires = time.Duration(expiresIn) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, expires)
|
return s.store.Set(s.refreshTokenKey(refreshToken), tokenData, expires)
|
||||||
|
|
|
||||||
|
|
@ -573,7 +573,7 @@ func TestTokenGeneration(t *testing.T) {
|
||||||
|
|
||||||
t.Run("generate refresh token", func(t *testing.T) {
|
t.Run("generate refresh token", func(t *testing.T) {
|
||||||
// Updated to use new generateRefreshToken signature with scope and subject
|
// 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.NoError(t, err)
|
||||||
assert.NotEmpty(t, token)
|
assert.NotEmpty(t, token)
|
||||||
assert.True(t, strings.HasPrefix(token, "rfk_"))
|
assert.True(t, strings.HasPrefix(token, "rfk_"))
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,7 @@ type UserProvider interface {
|
||||||
// Team Query Methods
|
// Team Query Methods
|
||||||
GetTeamsByOwner(ctx context.Context, ownerID string) ([]maps.MapStr, error)
|
GetTeamsByOwner(ctx context.Context, ownerID string) ([]maps.MapStr, error)
|
||||||
GetTeamsByMember(ctx context.Context, memberID 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)
|
GetTeamsByStatus(ctx context.Context, status string) ([]maps.MapStr, error)
|
||||||
CountTeamsByMember(ctx context.Context, memberID string) (int64, error)
|
CountTeamsByMember(ctx context.Context, memberID string) (int64, error)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -572,11 +572,11 @@ type TokenClaims struct {
|
||||||
Issuer string `json:"iss,omitempty"` // Token issuer
|
Issuer string `json:"iss,omitempty"` // Token issuer
|
||||||
Audience []string `json:"aud,omitempty"` // Token audience
|
Audience []string `json:"aud,omitempty"` // Token audience
|
||||||
JTI string `json:"jti,omitempty"` // JWT ID (for JWT tokens)
|
JTI string `json:"jti,omitempty"` // JWT ID (for JWT tokens)
|
||||||
|
|
||||||
// Extended claims for multi-tenancy and team support
|
// Extended claims for multi-tenancy and team support
|
||||||
TeamID string `json:"team_id,omitempty"` // Team identifier
|
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||||
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
||||||
|
|
||||||
// Extra claims for flexibility
|
// Extra claims for flexibility
|
||||||
Extra map[string]interface{} `json:"-"` // Additional custom claims (not serialized directly)
|
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
|
Scope string `json:"scope,omitempty"` // Access scope
|
||||||
SessionID string `json:"session_id,omitempty"` // Session ID
|
SessionID string `json:"session_id,omitempty"` // Session ID
|
||||||
UserID string `json:"user_id,omitempty"` // User ID
|
UserID string `json:"user_id,omitempty"` // User ID
|
||||||
|
|
||||||
// Extended fields for multi-tenancy and team support
|
// Extended fields for multi-tenancy and team support
|
||||||
TeamID string `json:"team_id,omitempty"` // Team identifier
|
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||||
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
||||||
|
|
@ -600,7 +600,7 @@ type JWTClaims struct {
|
||||||
ClientID string `json:"client_id"` // OAuth client ID
|
ClientID string `json:"client_id"` // OAuth client ID
|
||||||
Scope string `json:"scope,omitempty"` // Access scope
|
Scope string `json:"scope,omitempty"` // Access scope
|
||||||
TokenType string `json:"token_type"` // Token type
|
TokenType string `json:"token_type"` // Token type
|
||||||
|
|
||||||
// Extended claims for multi-tenancy and team support
|
// Extended claims for multi-tenancy and team support
|
||||||
TeamID string `json:"team_id,omitempty"` // Team identifier
|
TeamID string `json:"team_id,omitempty"` // Team identifier
|
||||||
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
TenantID string `json:"tenant_id,omitempty"` // Tenant identifier
|
||||||
|
|
|
||||||
|
|
@ -265,15 +265,17 @@ func generateSessionID() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendLoginCookies sends all necessary cookies for a successful login
|
// 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) {
|
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
|
||||||
|
|
||||||
// Send session ID cookie
|
// Send session ID cookie only if sessionID is provided
|
||||||
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
|
if sessionID != "" {
|
||||||
options := response.NewSecureCookieOptions().
|
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
|
||||||
WithExpires(expires).
|
options := response.NewSecureCookieOptions().
|
||||||
WithSameSite("Strict")
|
WithExpires(expires).
|
||||||
response.SendSecureCookieWithOptions(c, "session_id", sessionID, options)
|
WithSameSite("Strict")
|
||||||
|
response.SendSecureCookieWithOptions(c, "session_id", sessionID, options)
|
||||||
|
}
|
||||||
|
|
||||||
// MFA Temporary Access Token
|
// MFA Temporary Access Token
|
||||||
if loginResponse.Status == LoginStatusMFA {
|
if loginResponse.Status == LoginStatusMFA {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/yaoapp/kun/maps"
|
"github.com/yaoapp/kun/maps"
|
||||||
"github.com/yaoapp/yao/openapi/oauth"
|
"github.com/yaoapp/yao/openapi/oauth"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||||
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -308,6 +309,209 @@ func GinTeamUpdate(c *gin.Context) {
|
||||||
response.RespondWithSuccess(c, http.StatusOK, team)
|
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
|
// GinTeamDelete handles DELETE /teams/:id - Delete user team
|
||||||
func GinTeamDelete(c *gin.Context) {
|
func GinTeamDelete(c *gin.Context) {
|
||||||
// Get authorized user info
|
// Get authorized user info
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,11 @@ type UpdateTeamRequest struct {
|
||||||
Settings *TeamSettings `json:"settings,omitempty"`
|
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 ====
|
// ==== Member API Types ====
|
||||||
|
|
||||||
// MemberResponse represents a team member in API responses
|
// MemberResponse represents a team member in API responses
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
// Team Configuration
|
// Team Configuration
|
||||||
team.GET("/config", GinTeamConfig) // Get team configuration (requires authentication)
|
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 CRUD - Standard REST endpoints
|
||||||
team.GET("/", GinTeamList) // GET /teams - List user teams
|
team.GET("/", GinTeamList) // GET /teams - List user teams
|
||||||
team.POST("/", GinTeamCreate) // POST /teams - Create new team
|
team.POST("/", GinTeamCreate) // POST /teams - Create new team
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue