From 2b7040c834b0084c922bf549bcb58e8adc088099 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 3 Aug 2025 19:51:35 +0800 Subject: [PATCH] Implement ID token signing and user fingerprint management in OAuth - Added `SignIDToken` method to the OAuth service for signing ID tokens with user data and claims. - Introduced `MakeAccessToken` and `MakeRefreshToken` methods for generating access and refresh tokens. - Implemented user fingerprint management with `Subject` and `UserID` methods for mapping user IDs to unique NanoIDs. - Enhanced `LoginByUserID` to generate and return OIDC tokens and access tokens during user login. - Updated `OIDCUserInfo` mapping to include additional user attributes and improved handling of optional fields. --- openapi/oauth/signing.go | 119 ++++++++++++++ openapi/oauth/token.go | 101 ++++++++++++ openapi/oauth/types/oidc.go | 314 ++++++++++++++++++++++++++++++++++-- openapi/signin/login.go | 46 +++++- openapi/signin/types.go | 13 +- 5 files changed, 565 insertions(+), 28 deletions(-) diff --git a/openapi/oauth/signing.go b/openapi/oauth/signing.go index 2fdf41de..5f45551d 100644 --- a/openapi/oauth/signing.go +++ b/openapi/oauth/signing.go @@ -457,6 +457,125 @@ func (s *Service) VerifyToken(token string) (*types.TokenClaims, error) { return s.verifyOpaqueToken(token) } +// 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) { + if s.signingCerts == nil || s.signingCerts.SigningKey == nil { + return "", fmt.Errorf("signing certificates not initialized") + } + + // Check if userdata and subject are provided + if userdata == nil || userdata.Sub == "" { + return "", fmt.Errorf("userdata or userdata.Sub is required for ID token") + } + + tokenSubject := userdata.Sub + + now := time.Now() + + // Create OIDC ID Token claims + idTokenClaims := &types.OIDCIDToken{ + // Required ID Token claims + Iss: s.config.IssuerURL, + Sub: tokenSubject, + Aud: clientID, + Exp: now.Add(time.Duration(expiresIn) * time.Second).Unix(), + Iat: now.Unix(), + } + + // Create a map for custom claims that includes both standard and user info + claims := jwt.MapClaims{ + // Standard OIDC ID Token claims + "iss": idTokenClaims.Iss, + "sub": idTokenClaims.Sub, + "aud": idTokenClaims.Aud, + "exp": idTokenClaims.Exp, + "iat": idTokenClaims.Iat, + "jti": generateJTI(), + } + + // Add user information from userdata + // Add standard OIDC user claims if they exist + if userdata.Name != "" { + claims["name"] = userdata.Name + } + if userdata.GivenName != "" { + claims["given_name"] = userdata.GivenName + } + if userdata.FamilyName != "" { + claims["family_name"] = userdata.FamilyName + } + if userdata.MiddleName != "" { + claims["middle_name"] = userdata.MiddleName + } + if userdata.Nickname != "" { + claims["nickname"] = userdata.Nickname + } + if userdata.PreferredUsername != "" { + claims["preferred_username"] = userdata.PreferredUsername + } + if userdata.Profile != "" { + claims["profile"] = userdata.Profile + } + if userdata.Picture != "" { + claims["picture"] = userdata.Picture + } + if userdata.Website != "" { + claims["website"] = userdata.Website + } + if userdata.Email != "" { + claims["email"] = userdata.Email + } + if userdata.EmailVerified != nil { + claims["email_verified"] = *userdata.EmailVerified + } + if userdata.Gender != "" { + claims["gender"] = userdata.Gender + } + if userdata.Birthdate != "" { + claims["birthdate"] = userdata.Birthdate + } + if userdata.Zoneinfo != "" { + claims["zoneinfo"] = userdata.Zoneinfo + } + if userdata.Locale != "" { + claims["locale"] = userdata.Locale + } + if userdata.PhoneNumber != "" { + claims["phone_number"] = userdata.PhoneNumber + } + if userdata.PhoneNumberVerified != nil { + claims["phone_number_verified"] = *userdata.PhoneNumberVerified + } + if userdata.Address != nil { + claims["address"] = userdata.Address + } + if userdata.UpdatedAt != nil { + claims["updated_at"] = *userdata.UpdatedAt + } + + // Add scope if provided (useful for determining which claims to include) + if scope != "" { + claims["scope"] = scope + } + + // Create token with claims + token := jwt.NewWithClaims(getSigningMethod(s.config.Token.AccessTokenSigningAlg), claims) + + // Set key ID in header + token.Header["kid"] = s.GetKeyID() + + // Set token type in header + token.Header["typ"] = "JWT" + + // Sign token with private key + signedToken, err := token.SignedString(s.signingCerts.SigningKey) + if err != nil { + return "", fmt.Errorf("failed to sign ID token: %w", err) + } + + return signedToken, nil +} + // signJWTToken signs a JWT token using the configured signing algorithm func (s *Service) signJWTToken(tokenType, clientID, scope, subject string, expiresIn int) (string, error) { if s.signingCerts == nil || s.signingCerts.SigningKey == nil { diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index fd2e6144..7b023f20 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -8,6 +8,7 @@ import ( "strings" "time" + gonanoid "github.com/matoous/go-nanoid/v2" "github.com/yaoapp/yao/openapi/oauth/types" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -242,7 +243,79 @@ func (s *Service) ValidateTokenBinding(ctx context.Context, token string, bindin return result, nil } +// ============================================================================ +// Public Token helper methods for internal use +// ============================================================================ + +// MakeAccessToken generates a new access token with specific parameters and stores it +func (s *Service) MakeAccessToken(clientID, scope, subject string, expiresIn int) (string, error) { + return s.generateAccessTokenWithScope(clientID, scope, subject, expiresIn) +} + +// MakeRefreshToken generates a new refresh token with specific parameters and stores it +func (s *Service) MakeRefreshToken(clientID, scope, subject string) (string, error) { + return s.generateRefreshToken(clientID, scope, subject) +} + +// Subject converts a userID to a subject using NanoID fingerprint +func (s *Service) Subject(clientID, userID string) (string, error) { + // Check if mapping already exists for this clientID+userID + mappingKey := s.userMappingKey(clientID, userID) + if existingNanoID, exists := s.store.Get(mappingKey); exists { + if nanoIDStr, ok := existingNanoID.(string); ok { + return nanoIDStr, nil + } + } + + maxRetries := 5 + for i := 0; i < maxRetries; i++ { + // Generate 12-character NanoID + nanoID, err := generateNanoID(12) + if err != nil { + return "", fmt.Errorf("failed to generate NanoID: %w", err) + } + + // Check if this NanoID already exists for this client + key := s.userFingerprintKey(clientID, nanoID) + _, exists := s.store.Get(key) + if !exists { + // Store both mappings + // 1. clientID:nanoID -> userID + if err := s.store.Set(key, userID, 0); err != nil { + return "", fmt.Errorf("failed to store user fingerprint: %w", err) + } + // 2. clientID:userID -> nanoID (for checking existing mapping) + if err := s.store.Set(mappingKey, nanoID, 0); err != nil { + return "", fmt.Errorf("failed to store user mapping: %w", err) + } + return nanoID, nil + } + } + + return "", fmt.Errorf("failed to generate unique NanoID after %d retries", maxRetries) +} + +// UserID converts a subject to a userID using fingerprint lookup +func (s *Service) UserID(clientID, subject string) (string, error) { + key := s.userFingerprintKey(clientID, subject) + userID, exists := s.store.Get(key) + if !exists { + return "", fmt.Errorf("fingerprint not found") + } + + userIDStr, ok := userID.(string) + if !ok { + return "", fmt.Errorf("invalid userID format") + } + + return userIDStr, nil +} + +// MakeAuthorizationCode generates a new authorization code with specific parameters and stores it + +// ============================================================================ // Helper methods +// ============================================================================ // validateAudience validates if an audience is valid func (s *Service) validateAudience(audience string) error { @@ -520,6 +593,16 @@ func (s *Service) accessTokenKey(accessToken string) string { return fmt.Sprintf("%soauth:access_token:%s", s.prefix, accessToken) } +// userFingerprintKey generates a key for user fingerprint storage +func (s *Service) userFingerprintKey(clientID, nanoID string) string { + return fmt.Sprintf("%soauth:user_fingerprint:%s:%s", s.prefix, clientID, nanoID) +} + +// userMappingKey generates a key for reverse user mapping (clientID+userID -> nanoID) +func (s *Service) userMappingKey(clientID, userID string) string { + return fmt.Sprintf("%soauth:user_mapping:%s:%s", s.prefix, clientID, userID) +} + // generateExchangedToken generates a new token for token exchange func (s *Service) generateExchangedToken(subjectToken string, audience string) (string, error) { // Extract token prefix for tracking purposes @@ -556,3 +639,21 @@ func (s *Service) generateToken(tokenType string, clientID string) (string, erro return fmt.Sprintf("%s_%s_%s_%s", tokenType, clientID, timestamp, randomPart), nil } + +// ============================================================================ +// User Fingerprint Methods +// ============================================================================ + +// generateNanoID generates a Nano ID using the library +func generateNanoID(length int) (string, error) { + // URL-safe alphabet (no ambiguous characters like 0/O, 1/l/I) + const alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz" + return gonanoid.Generate(alphabet, length) +} + +// DeleteUserFingerprint removes a fingerprint mapping +func (s *Service) DeleteUserFingerprint(clientID, nanoID string) error { + key := s.userFingerprintKey(clientID, nanoID) + s.store.Del(key) + return nil +} diff --git a/openapi/oauth/types/oidc.go b/openapi/oauth/types/oidc.go index 117d3f56..800115e9 100644 --- a/openapi/oauth/types/oidc.go +++ b/openapi/oauth/types/oidc.go @@ -1,20 +1,304 @@ package types -// Map converts the OIDCUserInfo to a map[string]interface{} +import ( + "time" +) + +// Map converts the OIDCUserInfo to a map[string]interface{}, excluding empty values func (user OIDCUserInfo) Map() map[string]interface{} { - return map[string]interface{}{ - "sub": user.Sub, - "name": user.Name, - "given_name": user.GivenName, - "family_name": user.FamilyName, - "middle_name": user.MiddleName, - "nickname": user.Nickname, - "preferred_username": user.PreferredUsername, - "profile": user.Profile, - "picture": user.Picture, - "website": user.Website, - "email": user.Email, - "email_verified": user.EmailVerified, - "gender": user.Gender, + result := make(map[string]interface{}) + + // Only add non-empty string fields + if user.Sub != "" { + result["sub"] = user.Sub + } + if user.Name != "" { + result["name"] = user.Name + } + if user.GivenName != "" { + result["given_name"] = user.GivenName + } + if user.FamilyName != "" { + result["family_name"] = user.FamilyName + } + if user.MiddleName != "" { + result["middle_name"] = user.MiddleName + } + if user.Nickname != "" { + result["nickname"] = user.Nickname + } + if user.PreferredUsername != "" { + result["preferred_username"] = user.PreferredUsername + } + if user.Profile != "" { + result["profile"] = user.Profile + } + if user.Picture != "" { + result["picture"] = user.Picture + } + if user.Website != "" { + result["website"] = user.Website + } + if user.Email != "" { + result["email"] = user.Email + } + if user.Gender != "" { + result["gender"] = user.Gender + } + if user.Birthdate != "" { + result["birthdate"] = user.Birthdate + } + if user.Zoneinfo != "" { + result["zoneinfo"] = user.Zoneinfo + } + if user.Locale != "" { + result["locale"] = user.Locale + } + if user.PhoneNumber != "" { + result["phone_number"] = user.PhoneNumber + } + + // Only add non-nil boolean pointer fields + if user.EmailVerified != nil { + result["email_verified"] = user.EmailVerified + } + if user.PhoneNumberVerified != nil { + result["phone_number_verified"] = user.PhoneNumberVerified + } + + // Convert and add UpdatedAt if not nil + if converted := unixToMySQL(user.UpdatedAt); converted != nil { + result["updated_at"] = converted + } + + // Add address if present and has content + if user.Address != nil { + addressMap := make(map[string]interface{}) + if user.Address.Formatted != "" { + addressMap["formatted"] = user.Address.Formatted + } + if user.Address.StreetAddress != "" { + addressMap["street_address"] = user.Address.StreetAddress + } + if user.Address.Locality != "" { + addressMap["locality"] = user.Address.Locality + } + if user.Address.Region != "" { + addressMap["region"] = user.Address.Region + } + if user.Address.PostalCode != "" { + addressMap["postal_code"] = user.Address.PostalCode + } + if user.Address.Country != "" { + addressMap["country"] = user.Address.Country + } + if len(addressMap) > 0 { + result["address"] = addressMap + } + } + + // Include raw data if available + // if user.Raw != nil { + // // Merge raw data, but let structured fields take precedence + // for k, v := range user.Raw { + // if _, exists := result[k]; !exists && v != nil && v != "" { + // result[k] = v + // } + // } + // } + + return result +} + +// MakeOIDCUserInfo creates a new OIDCUserInfo from a map[string]interface{} +func MakeOIDCUserInfo(user map[string]interface{}) *OIDCUserInfo { + userInfo := &OIDCUserInfo{ + Raw: user, // Store original response + } + + // String fields with safe type assertion + if sub, ok := user["sub"].(string); ok { + userInfo.Sub = sub + } + if name, ok := user["name"].(string); ok { + userInfo.Name = name + } + if givenName, ok := user["given_name"].(string); ok { + userInfo.GivenName = givenName + } + if familyName, ok := user["family_name"].(string); ok { + userInfo.FamilyName = familyName + } + if middleName, ok := user["middle_name"].(string); ok { + userInfo.MiddleName = middleName + } + if nickname, ok := user["nickname"].(string); ok { + userInfo.Nickname = nickname + } + if preferredUsername, ok := user["preferred_username"].(string); ok { + userInfo.PreferredUsername = preferredUsername + } + if profile, ok := user["profile"].(string); ok { + userInfo.Profile = profile + } + if picture, ok := user["picture"].(string); ok { + userInfo.Picture = picture + } + if website, ok := user["website"].(string); ok { + userInfo.Website = website + } + if email, ok := user["email"].(string); ok { + userInfo.Email = email + } + if gender, ok := user["gender"].(string); ok { + userInfo.Gender = gender + } + if birthdate, ok := user["birthdate"].(string); ok { + userInfo.Birthdate = birthdate + } + if zoneinfo, ok := user["zoneinfo"].(string); ok { + userInfo.Zoneinfo = zoneinfo + } + if locale, ok := user["locale"].(string); ok { + userInfo.Locale = locale + } + if phoneNumber, ok := user["phone_number"].(string); ok { + userInfo.PhoneNumber = phoneNumber + } + + // Boolean pointer fields + if emailVerified, ok := user["email_verified"].(bool); ok { + userInfo.EmailVerified = &emailVerified + } + if phoneVerified, ok := user["phone_number_verified"].(bool); ok { + userInfo.PhoneNumberVerified = &phoneVerified + } + + // Updated_at field + if updatedAt, ok := user["updated_at"]; ok { + if converted := toUnixTimestamp(updatedAt); converted != nil { + if unixTime, ok := converted.(int64); ok { + userInfo.UpdatedAt = &unixTime + } + } + } + + // Address field (nested object) + if addressData, ok := user["address"].(map[string]interface{}); ok { + address := &OIDCAddress{} + if formatted, ok := addressData["formatted"].(string); ok { + address.Formatted = formatted + } + if streetAddress, ok := addressData["street_address"].(string); ok { + address.StreetAddress = streetAddress + } + if locality, ok := addressData["locality"].(string); ok { + address.Locality = locality + } + if region, ok := addressData["region"].(string); ok { + address.Region = region + } + if postalCode, ok := addressData["postal_code"].(string); ok { + address.PostalCode = postalCode + } + if country, ok := addressData["country"].(string); ok { + address.Country = country + } + userInfo.Address = address + } + + return userInfo +} + +// unixToMySQL converts interface{} to MySQL DATETIME string +func unixToMySQL(val interface{}) interface{} { + if val == nil { + return nil + } + + var unixTime int64 + switch v := val.(type) { + case int64: + unixTime = v + case *int64: + if v == nil { + return nil + } + unixTime = *v + case int: + unixTime = int64(v) + case float64: + unixTime = int64(v) + default: + return nil + } + + return time.Unix(unixTime, 0).UTC().Format("2006-01-02 15:04:05") +} + +// mysqlToUnix converts interface{} to Unix timestamp +func mysqlToUnix(val interface{}) interface{} { + if val == nil { + return nil + } + + var dateTime string + switch v := val.(type) { + case string: + dateTime = v + case *string: + if v == nil { + return nil + } + dateTime = *v + default: + return nil + } + + if dateTime == "" { + return nil + } + + // Try MySQL DATETIME format + if t, err := time.Parse("2006-01-02 15:04:05", dateTime); err == nil { + return t.Unix() + } + + // Try ISO format as fallback + if t, err := time.Parse("2006-01-02T15:04:05Z", dateTime); err == nil { + return t.Unix() + } + + return nil +} + +// toUnixTimestamp converts any interface{} to Unix timestamp +func toUnixTimestamp(val interface{}) interface{} { + if val == nil { + return nil + } + + switch v := val.(type) { + case int64: + return v + case *int64: + if v == nil { + return nil + } + return *v + case int: + return int64(v) + case float64: + return int64(v) + case string: + // Handle MySQL DATETIME or ISO format + return mysqlToUnix(v) + case *string: + if v == nil { + return nil + } + return mysqlToUnix(*v) + default: + return nil } } diff --git a/openapi/signin/login.go b/openapi/signin/login.go index 0a2f874a..216ab00d 100644 --- a/openapi/signin/login.go +++ b/openapi/signin/login.go @@ -2,6 +2,7 @@ package signin import ( "context" + "strings" "github.com/yaoapp/kun/log" "github.com/yaoapp/yao/openapi/oauth" @@ -93,13 +94,46 @@ func LoginByUserID(userid string, ip string) (*LoginResponse, error) { log.Warn("Failed to update last login: %s", err.Error()) } + var scopes []string + if v, ok := user["scopes"].([]string); ok { + scopes = v + } + + // Get Config form app.yao config () + clientID := "1234567890" + oidcExpiresIn := 3600 + accessTokenExpiresIn := 3600 + + subject, err := oauth.OAuth.Subject(clientID, userid) + if err != nil { + log.Warn("Failed to store user fingerprint: %s", err.Error()) + } + oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user) + oidcUserInfo.Sub = subject + + // OIDC Token + oidcToken, err := oauth.OAuth.SignIDToken(clientID, strings.Join(scopes, " "), oidcExpiresIn, oidcUserInfo) + if err != nil { + return nil, err + } + + // Access Token + accessToken, err := oauth.OAuth.MakeAccessToken(clientID, strings.Join(scopes, " "), subject, accessTokenExpiresIn) + if err != nil { + return nil, err + } + + // Refresh Token + refreshToken, err := oauth.OAuth.MakeRefreshToken(clientID, strings.Join(scopes, " "), subject) + if err != nil { + return nil, err + } return &LoginResponse{ - AccessToken: "mock_access_token", - IDToken: "mock_id_token", - RefreshToken: "mock_refresh_token", - ExpiresIn: 3600, + AccessToken: accessToken, + IDToken: oidcToken, + RefreshToken: refreshToken, + ExpiresIn: accessTokenExpiresIn, TokenType: "Bearer", - Scope: "openid profile email", - User: user, + Scope: strings.Join(scopes, " "), }, nil } diff --git a/openapi/signin/types.go b/openapi/signin/types.go index 7fe73992..da1404d7 100644 --- a/openapi/signin/types.go +++ b/openapi/signin/types.go @@ -153,13 +153,12 @@ type OIDCAddress = oauthtypes.OIDCAddress // LoginResponse represents the response for login type LoginResponse struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token,omitempty"` - RefreshToken string `json:"refresh_token,omitempty"` - ExpiresIn int `json:"expires_in,omitempty"` - TokenType string `json:"token_type,omitempty"` - Scope string `json:"scope,omitempty"` - User map[string]interface{} `json:"user,omitempty"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` } // Built-in preset mapping types