Merge pull request #1080 from trheyi/main

Implement ID token signing and user fingerprint management in OAuth
This commit is contained in:
Max 2025-08-03 19:53:05 +08:00 committed by GitHub
commit d55ecb06d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 565 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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