diff --git a/openapi/oauth/types/types.go b/openapi/oauth/types/types.go index 212e4ba1..bb63eac1 100644 --- a/openapi/oauth/types/types.go +++ b/openapi/oauth/types/types.go @@ -544,3 +544,67 @@ type ClientConfig struct { ClientCertificateRequired bool `json:"client_certificate_required"` // Optional: Require client certificates (default: false) ClientCertificateValidation string `json:"client_certificate_validation"` // Optional: Client certificate validation mode - none, optional, required (default: none) } + +// OIDC Standard Types + +// OIDCIDToken represents ID Token claims based on OIDC standard +// https://openid.net/specs/openid-connect-core-1_0.html#IDToken +type OIDCIDToken struct { + // REQUIRED ID Token Claims + Iss string `json:"iss"` // Issuer Identifier for the Issuer of the response + Sub string `json:"sub"` // Subject Identifier - locally unique identifier for the End-User + Aud string `json:"aud"` // Audience - OAuth 2.0 client_id of the Relying Party + Exp int64 `json:"exp"` // Expiration time - seconds from 1970-01-01T00:00:00Z UTC + Iat int64 `json:"iat"` // Issued at time - seconds from 1970-01-01T00:00:00Z UTC + + // OPTIONAL ID Token Claims + AuthTime *int64 `json:"auth_time,omitempty"` // Time when End-User authentication occurred + Nonce string `json:"nonce,omitempty"` // String value to associate Client session with ID Token + Acr string `json:"acr,omitempty"` // Authentication Context Class Reference + Amr []string `json:"amr,omitempty"` // Authentication Methods References + Azp string `json:"azp,omitempty"` // Authorized party - party to which ID Token was issued + + // Hash Claims for token validation + AtHash string `json:"at_hash,omitempty"` // Access Token hash value + CHash string `json:"c_hash,omitempty"` // Code hash value +} + +// OIDCUserInfo represents user information based on OIDC standard +type OIDCUserInfo struct { + // OIDC Standard Claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) + Sub string `json:"sub"` // Subject identifier (required) + Name string `json:"name,omitempty"` // Full name + GivenName string `json:"given_name,omitempty"` // Given name(s) or first name(s) + FamilyName string `json:"family_name,omitempty"` // Surname(s) or last name(s) + MiddleName string `json:"middle_name,omitempty"` // Middle name(s) + Nickname string `json:"nickname,omitempty"` // Casual name + PreferredUsername string `json:"preferred_username,omitempty"` // Shorthand name + Profile string `json:"profile,omitempty"` // Profile page URL + Picture string `json:"picture,omitempty"` // Profile picture URL + Website string `json:"website,omitempty"` // Web page or blog URL + Email string `json:"email,omitempty"` // Email address + EmailVerified *bool `json:"email_verified,omitempty"` // Email verification status + Gender string `json:"gender,omitempty"` // Gender + Birthdate string `json:"birthdate,omitempty"` // Birthday (YYYY-MM-DD format) + Zoneinfo string `json:"zoneinfo,omitempty"` // Time zone info + Locale string `json:"locale,omitempty"` // Locale (language-country) + PhoneNumber string `json:"phone_number,omitempty"` // Phone number + PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"` // Phone verification status + UpdatedAt *int64 `json:"updated_at,omitempty"` // Time of last update (seconds since epoch) + + // OIDC Address Claim (structured) + Address *OIDCAddress `json:"address,omitempty"` // Physical mailing address + + // Raw response for debugging and custom processing + Raw map[string]interface{} `json:"raw,omitempty"` // Original provider response +} + +// OIDCAddress represents the OIDC address claim structure +type OIDCAddress struct { + Formatted string `json:"formatted,omitempty"` // Full mailing address + StreetAddress string `json:"street_address,omitempty"` // Street address + Locality string `json:"locality,omitempty"` // City or locality + Region string `json:"region,omitempty"` // State, province, prefecture, or region + PostalCode string `json:"postal_code,omitempty"` // Zip code or postal code + Country string `json:"country,omitempty"` // Country name +} diff --git a/openapi/signin/api.go b/openapi/signin/api.go index f5d828bd..a538a1db 100644 --- a/openapi/signin/api.go +++ b/openapi/signin/api.go @@ -22,7 +22,7 @@ import ( func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.GET("/signin", getConfig) group.POST("/signin", signin) - group.POST("/signin/authback/:provider", authback) + group.POST("/signin/oauth/:provider/authback", authback) group.GET("/signin/oauth/:provider/authorize", getOAuthAuthorizationURL) group.POST("/signin/oauth/:provider/authorize/prepare", authbackPrepare) // Receive the post data and forward to the authback handler } @@ -63,6 +63,7 @@ func signin(c *gin.Context) {} func authbackPrepare(c *gin.Context) { code := c.PostForm("code") state := c.PostForm("state") + user := c.PostForm("user") // form_post may include user info providerID := c.Param("provider") redirectURI, err := getRedirectURI(providerID, state) if err != nil { @@ -74,6 +75,11 @@ func authbackPrepare(c *gin.Context) { return } + // Cache user info if provided (form_post mode) + if user != "" { + saveUserInfo(providerID, state, user) + } + params := url.Values{} params.Add("code", code) params.Add("state", state) @@ -138,8 +144,7 @@ func authback(c *gin.Context) { // if response mode is form_post if provider.ResponseMode == "form_post" { // Replace the redirectURI to - pathname := strings.Replace(c.Request.URL.Path, "/signin/authback/", "/signin/oauth/", 1) + "/authorize/prepare" - fmt.Println(pathname) + pathname := strings.TrimSuffix(c.Request.URL.Path, "/authback") + "/authorize/prepare" newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c) if err != nil { log.Error("Failed to reconstruct redirectURI: %v", err) @@ -152,7 +157,21 @@ func authback(c *gin.Context) { redirectURI = newRedirectURI } - // Remove the state from the session and cache + // Get AccessToken + tokenResponse, err := provider.AccessToken(params.Code, redirectURI) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Read cached user info before cleaning up (for form_post mode) + cachedUserInfo, _ := getUserInfo(providerID, params.State) + + // Remove the state from the session and cache (also cleans up user cache automatically) err = removeState(providerID, sid) if err != nil { log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state") @@ -164,8 +183,16 @@ func authback(c *gin.Context) { return } - // Get UserInfo - tokenResponse, err := provider.AccessToken(params.Code, redirectURI) + // Get UserInfo - use different method based on user_info_source + var userInfo *OAuthUserInfoResponse + if provider.UserInfoSource == UserInfoSourceIDToken { + // For OAuth providers that use id_token, pass cached user info for merging + userInfo, err = provider.GetUserInfoFromTokenResponse(tokenResponse, cachedUserInfo) + } else { + // For standard OAuth providers that use userinfo endpoint + userInfo, err = provider.GetUserInfo(tokenResponse.AccessToken, tokenResponse.TokenType) + } + if err != nil { errorResp := &response.ErrorResponse{ Code: response.ErrInvalidRequest.Code, @@ -177,9 +204,9 @@ func authback(c *gin.Context) { // Respond with success response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{ - "params": params, - "token": tokenResponse, - "provider": provider, + "params": params, + "token": tokenResponse, + "user": userInfo, }) } @@ -400,6 +427,43 @@ func removeRedirectURI(providerID, state string) error { return nil } +// saveUserInfo saves the user info to cache (for form_post mode) +func saveUserInfo(providerID, state, userInfo string) error { + key := fmt.Sprintf("oauth_user_info_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return err + } + store.Set(key, userInfo, 20*time.Minute) + return nil +} + +// getUserInfo gets the user info from cache +func getUserInfo(providerID, state string) (string, error) { + key := fmt.Sprintf("oauth_user_info_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return "", err + } + + value, ok := store.Get(key) + if !ok || value == nil { + return "", fmt.Errorf("user info not found") + } + return value.(string), nil +} + +// removeUserInfo removes the user info from cache +func removeUserInfo(providerID, state string) error { + key := fmt.Sprintf("oauth_user_info_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return err + } + store.Del(key) + return nil +} + func removeState(providerID, sid string) error { // Get the state from the session state, err := session.Global().ID(sid).Get(fmt.Sprintf("oauth_state_%s", providerID)) @@ -407,8 +471,16 @@ func removeState(providerID, sid string) error { return err } - // Remove the redirect URI from the cache - removeRedirectURI(providerID, state.(string)) + // Safely convert state to string + stateStr, ok := state.(string) + if !ok { + return fmt.Errorf("invalid state type: expected string, got %T", state) + } + + // Remove all related cached data + removeRedirectURI(providerID, stateStr) + removeUserInfo(providerID, stateStr) + return session.Global().ID(sid).Del(fmt.Sprintf("oauth_state_%s", providerID)) } @@ -419,7 +491,13 @@ func validateState(providerID, sid, state string) error { return err } - if value != state { + // Safely convert value to string + stateStr, ok := value.(string) + if !ok { + return fmt.Errorf("invalid state type: expected string, got %T", value) + } + + if stateStr != state { return fmt.Errorf("invalid state") } diff --git a/openapi/signin/provider.go b/openapi/signin/provider.go index f7d4a35e..2834cda0 100644 --- a/openapi/signin/provider.go +++ b/openapi/signin/provider.go @@ -3,6 +3,7 @@ package signin import ( "crypto/ecdsa" "crypto/hmac" + "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" @@ -10,8 +11,10 @@ import ( "encoding/json" "encoding/pem" "fmt" + "math/big" "os" "path/filepath" + "strconv" "strings" "time" @@ -19,8 +22,208 @@ import ( "github.com/yaoapp/gou/application" "github.com/yaoapp/gou/http" "github.com/yaoapp/kun/log" + "github.com/yaoapp/kun/utils" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" ) +// convertToString converts various types to string, avoiding scientific notation for numbers +func (p *Provider) convertToString(value interface{}) string { + // Handle nil values + if value == nil { + return "" + } + + switch v := value.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case float64: + // Check if it's actually an integer value + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case float32: + // Check if it's actually an integer value + if v == float32(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(float64(v), 'f', -1, 32) + case bool: + return strconv.FormatBool(v) + case []interface{}: + // Handle empty arrays + if len(v) == 0 { + return "" + } + return fmt.Sprintf("%v", v) + default: + return fmt.Sprintf("%v", v) + } +} + +// getPresetMappings returns built-in field mappings for different providers +func getPresetMappings() map[string]map[string]string { + return map[string]map[string]string{ + MappingGoogle: { + "sub": "sub", + "id": "sub", // fallback + "name": "name", + "given_name": "given_name", + "family_name": "family_name", + "email": "email", + "email_verified": "email_verified", + "picture": "picture", + "locale": "locale", + }, + MappingGitHub: { + "id": "sub", + "login": "preferred_username", + "name": "name", + "email": "email", + "avatar_url": "picture", + "blog": "website", + "html_url": "profile", + "location": "address.formatted", + "updated_at": "updated_at", + }, + MappingMicrosoft: { + "id": "sub", + "displayName": "name", + "givenName": "given_name", + "surname": "family_name", + "mail": "email", + "userPrincipalName": "preferred_username", + "mobilePhone": "phone_number", // Priority 1: Personal mobile phone + "businessPhones[0]": "phone_number", // Priority 2: First business phone using array access + "preferredLanguage": "locale", + "officeLocation": "address.locality", + // jobTitle will remain in raw data as OIDC has no direct equivalent + }, + MappingApple: { + "sub": "sub", + "email": "email", + "email_verified": "email_verified", + "preferred_username": "preferred_username", + // form_post provides name information in nested structure - using generic nested access + "name.firstName": "given_name", + "name.lastName": "family_name", + "name": "name", // Full name object will be handled by mapping logic + }, + MappingWeChat: { + "openid": "sub", + "nickname": "nickname", + "headimgurl": "picture", + "sex": "gender", + "country": "address.country", + "province": "address.region", + "city": "address.locality", + }, + MappingGeneric: { + "sub": "sub", + "id": "sub", + "user_id": "sub", + "openid": "sub", + "name": "name", + "display_name": "name", + "displayName": "name", + "full_name": "name", + "fullName": "name", + "given_name": "given_name", + "first_name": "given_name", + "firstName": "given_name", + "family_name": "family_name", + "last_name": "family_name", + "lastName": "family_name", + "surname": "family_name", + "middle_name": "middle_name", + "middleName": "middle_name", + "nickname": "nickname", + "nick": "nickname", + "preferred_username": "preferred_username", + "username": "preferred_username", + "login": "preferred_username", + "screen_name": "preferred_username", + "user_name": "preferred_username", + "profile": "profile", + "profile_url": "profile", + "picture": "picture", + "avatar": "picture", + "avatar_url": "picture", + "profile_image_url": "picture", + "headimgurl": "picture", + "website": "website", + "blog": "website", + "url": "website", + "email": "email", + "mail": "email", + "email_address": "email", + "email_verified": "email_verified", + "verified_email": "email_verified", + "gender": "gender", + "sex": "gender", + "birthdate": "birthdate", + "birthday": "birthdate", + "birth_date": "birthdate", + "zoneinfo": "zoneinfo", + "timezone": "zoneinfo", + "time_zone": "zoneinfo", + "locale": "locale", + "language": "locale", + "lang": "locale", + "phone_number": "phone_number", + "phone": "phone_number", + "mobile": "phone_number", + "mobile_phone": "phone_number", + "mobilePhone": "phone_number", + "updated_at": "updated_at", + "last_modified": "updated_at", + "modified_at": "updated_at", + }, + } +} + +// getFieldMapping resolves the mapping configuration and returns the actual field mapping +func (p *Provider) getFieldMapping() map[string]string { + if p.Mapping == nil { + // Case 3: nil/empty - use generic mapping + return getPresetMappings()[MappingGeneric] + } + + switch mapping := p.Mapping.(type) { + case string: + // Case 1: string (preset enum) + if presetMapping, exists := getPresetMappings()[mapping]; exists { + return presetMapping + } + // If preset not found, fallback to generic + log.Warn("Unknown preset mapping '%s', falling back to generic mapping", mapping) + return getPresetMappings()[MappingGeneric] + + case map[string]interface{}: + // Convert map[string]interface{} to map[string]string + result := make(map[string]string) + for k, v := range mapping { + if strVal, ok := v.(string); ok { + result[k] = strVal + } + } + return result + + case map[string]string: + // Case 2: map[string]string (custom mapping) + return mapping + + default: + // Invalid type, fallback to generic + log.Warn("Invalid mapping type %T, falling back to generic mapping", mapping) + return getPresetMappings()[MappingGeneric] + } +} + // GetClientSecret gets the client secret for the provider func (p *Provider) GetClientSecret() (string, error) { if p.ClientSecret != "" { @@ -36,12 +239,620 @@ func (p *Provider) GetClientSecret() (string, error) { } // GetUserInfo gets the user information from the provider -func (p *Provider) GetUserInfo(accessToken string) (*OAuthUserInfoResponse, error) { +func (p *Provider) GetUserInfo(accessToken string, tokenType string) (*oauthtypes.OIDCUserInfo, error) { + if accessToken == "" { + return nil, fmt.Errorf("access_token is required") + } + + // Set default token type if not provided + if tokenType == "" { + tokenType = "Bearer" + } + + // Determine user info source (default to "endpoint") + userInfoSource := p.UserInfoSource + if userInfoSource == "" { + userInfoSource = UserInfoSourceEndpoint + } + + // Handle different user info sources + switch userInfoSource { + case UserInfoSourceEndpoint: + return p.getUserInfoFromEndpoint(accessToken, tokenType) + case UserInfoSourceIDToken: + // For id_token source, we need a different approach since we need the token response + return nil, fmt.Errorf("id_token source requires GetUserInfoFromTokenResponse method instead") + case UserInfoSourceAccessToken: + return p.getUserInfoFromAccessToken(accessToken) + default: + return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) + } +} + +// GetUserInfoFromTokenResponse gets user info from complete token response (for Apple OAuth with id_token) +func (p *Provider) GetUserInfoFromTokenResponse(tokenResponse *OAuthTokenResponse, mergeUserInfo ...string) (*oauthtypes.OIDCUserInfo, error) { + if tokenResponse == nil { + return nil, fmt.Errorf("token response is required") + } + + // Determine user info source (default to "endpoint") + userInfoSource := p.UserInfoSource + if userInfoSource == "" { + userInfoSource = UserInfoSourceEndpoint + } + + // Get user info from different sources + var userInfo *oauthtypes.OIDCUserInfo + var err error + + switch userInfoSource { + case UserInfoSourceEndpoint: + userInfo, err = p.getUserInfoFromEndpoint(tokenResponse.AccessToken, tokenResponse.TokenType) + case UserInfoSourceIDToken: + if tokenResponse.IDToken == "" { + return nil, fmt.Errorf("id_token not found in token response") + } + // Get raw claims from ID token + rawClaims, err := p.verifyIDTokenAndGetClaims(tokenResponse.IDToken) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + // Merge cached user info into raw claims before mapping + if len(mergeUserInfo) > 0 && mergeUserInfo[0] != "" { + p.mergeFormPostDataIntoClaims(rawClaims, mergeUserInfo[0]) + } + + // Map the merged claims to our standard user info structure + userInfo = p.mapUserInfoResponse(rawClaims) + case UserInfoSourceAccessToken: + userInfo, err = p.getUserInfoFromAccessToken(tokenResponse.AccessToken) + default: + return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) + } + + if err != nil { + return nil, err + } + + return userInfo, nil +} + +// mergeFormPostDataIntoClaims merges user info from form_post data into raw claims before mapping +func (p *Provider) mergeFormPostDataIntoClaims(rawClaims map[string]interface{}, cachedUserInfo string) { + var userData map[string]interface{} + if err := json.Unmarshal([]byte(cachedUserInfo), &userData); err != nil { + log.Warn("Failed to parse cached user info: %v", err) + return + } + + // Merge cached data into raw claims, but preserve existing claims (ID Token data is more reliable) + // The mapping logic will handle all field conversions + for key, value := range userData { + if _, exists := rawClaims[key]; !exists { + rawClaims[key] = value + } + } +} + +// getUserInfoFromEndpoint gets user info from a dedicated endpoint (default behavior) +func (p *Provider) getUserInfoFromEndpoint(accessToken string, tokenType string) (*oauthtypes.OIDCUserInfo, error) { if p.Endpoints == nil { return nil, fmt.Errorf("endpoints not found, set endpoints at least one") } - return nil, nil + if p.Endpoints.UserInfo == "" { + return nil, fmt.Errorf("user_info endpoint not found, set user_info endpoint at least one") + } + + // Create HTTP request with authorization header + req := http.New(p.Endpoints.UserInfo). + SetHeader("Authorization", fmt.Sprintf("%s %s", tokenType, accessToken)). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + // Make the GET request + resp := req.Get() + if resp == nil { + return nil, fmt.Errorf("failed to make user info request: no response") + } + + // Check for HTTP errors + if resp.Code != 200 { + if resp.Data != nil { + + // === Parse the response data === + if data, ok := resp.Data.(map[string]interface{}); ok { + // Handle standard OAuth error format + if err, ok := data["error_description"]; ok { + return nil, fmt.Errorf("%v", err) + } + if err, ok := data["error"]; ok { + // Handle Microsoft Graph nested error format + if errorObj, isMap := err.(map[string]interface{}); isMap { + if code, hasCode := errorObj["code"]; hasCode { + if message, hasMessage := errorObj["message"]; hasMessage && message != "" { + return nil, fmt.Errorf("Microsoft Graph error %v: %v", code, message) + } + return nil, fmt.Errorf("Microsoft Graph error: %v", code) + } + } + return nil, fmt.Errorf("%v", err) + } + } + } + + if resp.Message != "" { + return nil, fmt.Errorf("user info request failed with status %d: %s", resp.Code, resp.Message) + } + return nil, fmt.Errorf("user info request failed with status %d", resp.Code) + } + + // Parse the response data + var rawData map[string]interface{} + switch data := resp.Data.(type) { + case map[string]interface{}: + rawData = data + case []byte: + if err := json.Unmarshal(data, &rawData); err != nil { + return nil, fmt.Errorf("failed to parse user info response from bytes: %w", err) + } + case string: + if err := json.Unmarshal([]byte(data), &rawData); err != nil { + return nil, fmt.Errorf("failed to parse user info response from string: %w", err) + } + default: + return nil, fmt.Errorf("unexpected response data type: %T", data) + } + + // Map the raw response to our standard structure + userInfo := p.mapUserInfoResponse(rawData) + + return userInfo, nil +} + +// getUserInfoFromIDToken extracts user info from ID token (JWT) with signature verification +func (p *Provider) getUserInfoFromIDToken(idToken string) (*oauthtypes.OIDCUserInfo, error) { + // Verify JWT signature and get raw claims + rawClaims, err := p.verifyIDTokenAndGetClaims(idToken) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %w", err) + } + + // Map the raw JWT claims to our standard user info structure + userInfo := p.mapUserInfoResponse(rawClaims) + + return userInfo, nil +} + +// verifyIDTokenAndGetClaims verifies ID token signature and returns raw claims for user info mapping +func (p *Provider) verifyIDTokenAndGetClaims(idToken string) (map[string]interface{}, error) { + // Parse token to get header for key ID + token, err := jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) { + // Verify signing method + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Get key ID from token header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing key ID in token header") + } + + // Get public key from JWKS endpoint for verification + publicKey, err := p.getJWKSPublicKey(kid) + if err != nil { + return nil, fmt.Errorf("failed to get JWKS public key: %w", err) + } + + return publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse/verify JWT: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid JWT token") + } + + fmt.Println("--- TEST ---") + utils.Dump(token.Claims) + fmt.Println("---------------") + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("failed to extract JWT claims") + } + + // Basic validation + if aud, ok := claims["aud"].(string); ok && aud != p.ClientID { + return nil, fmt.Errorf("invalid audience: %s", aud) + } + if exp, ok := claims["exp"].(float64); ok && time.Now().Unix() > int64(exp) { + return nil, fmt.Errorf("token expired") + } + + // Convert jwt.MapClaims to map[string]interface{} + rawClaims := make(map[string]interface{}) + for key, value := range claims { + rawClaims[key] = value + } + + return rawClaims, nil +} + +// getJWKSPublicKey fetches public key from provider's JWKS endpoint +func (p *Provider) getJWKSPublicKey(keyID string) (interface{}, error) { + // Check if JWKS endpoint is configured + if p.Endpoints == nil || p.Endpoints.JWKS == "" { + return nil, fmt.Errorf("JWKS endpoint not configured") + } + + jwksURL := p.Endpoints.JWKS + + // Make HTTP request to get JWKS + req := http.New(jwksURL). + SetHeader("Accept", "application/json"). + SetHeader("User-Agent", "Yao-OAuth-Client/1.0") + + resp := req.Get() + if resp == nil { + return nil, fmt.Errorf("failed to fetch JWKS from %s: no response", jwksURL) + } + + if resp.Code != 200 { + return nil, fmt.Errorf("failed to fetch JWKS from %s: status %d", jwksURL, resp.Code) + } + + // Parse JWKS response + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + // Handle different response data types + switch data := resp.Data.(type) { + case map[string]interface{}: + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWKS response: %w", err) + } + if err := json.Unmarshal(jsonBytes, &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + case []byte: + if err := json.Unmarshal(data, &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + case string: + if err := json.Unmarshal([]byte(data), &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS response: %w", err) + } + default: + return nil, fmt.Errorf("unexpected JWKS response data type: %T", data) + } + + // Find the key with matching kid + for _, key := range jwks.Keys { + if key.Kid == keyID && key.Kty == "RSA" { + // Decode RSA public key components + nBytes, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return nil, fmt.Errorf("failed to decode RSA modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return nil, fmt.Errorf("failed to decode RSA exponent: %w", err) + } + + // Convert exponent bytes to int + var eInt int64 + for _, b := range eBytes { + eInt = eInt<<8 + int64(b) + } + + // Create RSA public key + rsaKey := &rsa.PublicKey{ + N: big.NewInt(0).SetBytes(nBytes), + E: int(eInt), + } + + return rsaKey, nil + } + } + + return nil, fmt.Errorf("public key not found for key ID: %s", keyID) +} + +// getUserInfoFromAccessToken gets user info from access token response +func (p *Provider) getUserInfoFromAccessToken(accessToken string) (*oauthtypes.OIDCUserInfo, error) { + // This is a placeholder implementation + // In a real implementation, this would parse structured data from the access token response + // or decode a JWT access token if the provider uses JWT access tokens + return &oauthtypes.OIDCUserInfo{ + Sub: "access_token_user", // Placeholder + Raw: map[string]interface{}{ + "note": "User info extracted from access token", + "access_token": accessToken, + }, + }, nil +} + +// mapUserInfoResponse maps raw OAuth user info response to our standard structure +func (p *Provider) mapUserInfoResponse(rawData map[string]interface{}) *oauthtypes.OIDCUserInfo { + userInfo := &oauthtypes.OIDCUserInfo{ + Raw: rawData, // Keep raw data for debugging/custom processing + } + + // Get the appropriate field mapping (preset, custom, or generic) + fieldMapping := p.getFieldMapping() + + // Apply field mappings with support for nested field access + for sourceField, targetField := range fieldMapping { + var value interface{} + var exists bool + + // Check if it's a nested field (contains dots or array notation) + if strings.Contains(sourceField, ".") || strings.Contains(sourceField, "[") { + value = p.getNestedValue(rawData, sourceField) + exists = (value != nil) + } else { + // Simple field access + value, exists = rawData[sourceField] + } + + if exists { + p.setUserInfoField(userInfo, targetField, value) + } + } + + // Post-processing: set fallback values + p.applyFallbackValues(userInfo, rawData) + + return userInfo +} + +// getNestedValue retrieves a value from nested object/array using dot notation and array indexing +// Supports: "name.firstName", "address.country", "businessPhones[0]", "roles[1].name" +func (p *Provider) getNestedValue(data map[string]interface{}, path string) interface{} { + // Split path by dots + parts := strings.Split(path, ".") + current := interface{}(data) + + for _, part := range parts { + // Handle array indexing: fieldName[index] + if strings.Contains(part, "[") && strings.HasSuffix(part, "]") { + // Extract field name and index + openBracket := strings.Index(part, "[") + fieldName := part[:openBracket] + indexStr := part[openBracket+1 : len(part)-1] + + // Get the field first + if currentMap, ok := current.(map[string]interface{}); ok { + if field, exists := currentMap[fieldName]; exists { + current = field + } else { + return nil + } + } else { + return nil + } + + // Handle array access + if currentArray, ok := current.([]interface{}); ok { + if index, err := strconv.Atoi(indexStr); err == nil && index >= 0 && index < len(currentArray) { + current = currentArray[index] + } else { + return nil + } + } else { + return nil + } + } else { + // Handle simple field access + if currentMap, ok := current.(map[string]interface{}); ok { + if field, exists := currentMap[part]; exists { + current = field + } else { + return nil + } + } else { + return nil + } + } + } + + return current +} + +// setUserInfoField sets a field in the user info structure +func (p *Provider) setUserInfoField(userInfo *oauthtypes.OIDCUserInfo, fieldName string, value interface{}) { + // Handle nested address fields + if strings.HasPrefix(fieldName, "address.") { + stringValue := p.convertToString(value) + // Skip empty values + if stringValue == "" { + return + } + + if userInfo.Address == nil { + userInfo.Address = &oauthtypes.OIDCAddress{} + } + + addressField := strings.TrimPrefix(fieldName, "address.") + + switch addressField { + case "formatted": + userInfo.Address.Formatted = stringValue + case "street_address": + userInfo.Address.StreetAddress = stringValue + case "locality": + userInfo.Address.Locality = stringValue + case "region": + userInfo.Address.Region = stringValue + case "postal_code": + userInfo.Address.PostalCode = stringValue + case "country": + userInfo.Address.Country = stringValue + } + return + } + + stringValue := p.convertToString(value) + + // Skip empty values for most fields + if stringValue == "" && fieldName != "phone_number" { + return + } + + switch fieldName { + // OIDC Standard Claims + case "sub": + userInfo.Sub = stringValue + case "name": + // Handle name as object (e.g., Apple form_post: {"firstName": "John", "lastName": "Doe"}) + if nameObj, ok := value.(map[string]interface{}); ok { + var nameParts []string + if firstName, exists := nameObj["firstName"]; exists { + if firstNameStr := p.convertToString(firstName); firstNameStr != "" { + nameParts = append(nameParts, firstNameStr) + if userInfo.GivenName == "" { + userInfo.GivenName = firstNameStr + } + } + } + if lastName, exists := nameObj["lastName"]; exists { + if lastNameStr := p.convertToString(lastName); lastNameStr != "" { + nameParts = append(nameParts, lastNameStr) + if userInfo.FamilyName == "" { + userInfo.FamilyName = lastNameStr + } + } + } + if len(nameParts) > 0 { + userInfo.Name = strings.Join(nameParts, " ") + } + } else { + // Handle name as string + userInfo.Name = stringValue + } + case "given_name": + userInfo.GivenName = stringValue + case "family_name": + userInfo.FamilyName = stringValue + case "middle_name": + userInfo.MiddleName = stringValue + case "nickname": + userInfo.Nickname = stringValue + case "preferred_username": + userInfo.PreferredUsername = stringValue + case "profile": + userInfo.Profile = stringValue + case "picture": + userInfo.Picture = stringValue + case "website": + userInfo.Website = stringValue + case "email": + userInfo.Email = stringValue + case "email_verified": + if boolValue, ok := value.(bool); ok { + userInfo.EmailVerified = &boolValue + } + case "gender": + // Handle special gender conversion for WeChat + if floatValue, ok := value.(float64); ok { + switch int(floatValue) { + case 1: + userInfo.Gender = "male" + case 2: + userInfo.Gender = "female" + default: + userInfo.Gender = "unknown" + } + } else { + userInfo.Gender = stringValue + } + case "birthdate": + userInfo.Birthdate = stringValue + case "zoneinfo": + userInfo.Zoneinfo = stringValue + case "locale": + userInfo.Locale = stringValue + case "phone_number": + // Only set if we don't already have a phone number + if userInfo.PhoneNumber != "" { + return + } + + // Handle array type for Microsoft businessPhones + if phoneArray, ok := value.([]interface{}); ok && len(phoneArray) > 0 { + // Take the first non-empty phone number from the array + for _, phone := range phoneArray { + if phoneStr := p.convertToString(phone); phoneStr != "" { + userInfo.PhoneNumber = phoneStr + break + } + } + } else { + // Handle single phone number (mobilePhone) + if stringValue != "" { + userInfo.PhoneNumber = stringValue + } + } + case "phone_number_verified": + if boolValue, ok := value.(bool); ok { + userInfo.PhoneNumberVerified = &boolValue + } + case "updated_at": + if intValue, ok := value.(int64); ok { + userInfo.UpdatedAt = &intValue + } else if stringValue, ok := value.(string); ok { + // Handle ISO 8601 time strings (e.g., from GitHub) + if parsedTime, err := time.Parse(time.RFC3339, stringValue); err == nil { + timestamp := parsedTime.Unix() + userInfo.UpdatedAt = ×tamp + } + } + } +} + +// applyFallbackValues applies fallback values and data cleanup +func (p *Provider) applyFallbackValues(userInfo *oauthtypes.OIDCUserInfo, rawData map[string]interface{}) { + // OIDC Standard: If no name but have given_name/family_name, combine them + if userInfo.Name == "" && (userInfo.GivenName != "" || userInfo.FamilyName != "") { + parts := []string{} + if userInfo.GivenName != "" { + parts = append(parts, userInfo.GivenName) + } + if userInfo.MiddleName != "" { + parts = append(parts, userInfo.MiddleName) + } + if userInfo.FamilyName != "" { + parts = append(parts, userInfo.FamilyName) + } + userInfo.Name = strings.Join(parts, " ") + } + + // Set preferred_username fallbacks + if userInfo.PreferredUsername == "" && userInfo.Email != "" { + if atIndex := strings.Index(userInfo.Email, "@"); atIndex > 0 { + userInfo.PreferredUsername = userInfo.Email[:atIndex] + } + } + + // OIDC requires Sub to be always set + if userInfo.Sub == "" { + log.Error("Subject identifier (sub) not found in OAuth response for provider '%s'", p.ID) + } } // GenerateClientSecret generates client secret based on the configured generator type diff --git a/openapi/signin/types.go b/openapi/signin/types.go index 3c6ff4ab..2cb898c8 100644 --- a/openapi/signin/types.go +++ b/openapi/signin/types.go @@ -1,5 +1,9 @@ package signin +import ( + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" +) + // Config represents the signin page configuration type Config struct { Title string `json:"title,omitempty"` @@ -60,18 +64,19 @@ type RegisterConfig struct { // Provider represents a third party login provider type Provider struct { - ID string `json:"id,omitempty"` - Title string `json:"title,omitempty"` - Logo string `json:"logo,omitempty"` - Color string `json:"color,omitempty"` - TextColor string `json:"text_color,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"` - Scopes []string `json:"scopes,omitempty"` - ResponseMode string `json:"response_mode,omitempty"` - Endpoints *Endpoints `json:"endpoints,omitempty"` - Mapping map[string]string `json:"mapping,omitempty"` + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Logo string `json:"logo,omitempty"` + Color string `json:"color,omitempty"` + TextColor string `json:"text_color,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"` + Scopes []string `json:"scopes,omitempty"` + ResponseMode string `json:"response_mode,omitempty"` + UserInfoSource string `json:"user_info_source,omitempty"` // "endpoint" (default) | "id_token" | "access_token" + Endpoints *Endpoints `json:"endpoints,omitempty"` + Mapping interface{} `json:"mapping,omitempty"` // string (preset) | map[string]string (custom) | nil (generic) } // SecretGenerator represents the client secret generator configuration @@ -88,6 +93,7 @@ type Endpoints struct { Authorization string `json:"authorization,omitempty"` Token string `json:"token,omitempty"` UserInfo string `json:"user_info,omitempty"` + JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification } // ==== API Types ==== @@ -121,6 +127,7 @@ type OAuthTokenResponse struct { ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` Scope string `json:"scope"` + IDToken string `json:"id_token,omitempty"` // JWT token containing user info (Apple, etc.) Error string `json:"error"` ErrorDesc string `json:"error_description"` } @@ -134,11 +141,25 @@ type OAuthTokenRequest struct { RedirectURI string `json:"redirect_uri,omitempty" form:"redirect_uri,omitempty"` } -// OAuthUserInfoResponse represents the user information from OAuth provider -type OAuthUserInfoResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Avatar string `json:"avatar"` - Username string `json:"username"` -} +// OAuthUserInfoResponse is an alias for OIDC standard user information type +type OAuthUserInfoResponse = oauthtypes.OIDCUserInfo + +// OIDCAddress is an alias for OIDC standard address claim type +type OIDCAddress = oauthtypes.OIDCAddress + +// Built-in preset mapping types +const ( + MappingGoogle = "google" + MappingGitHub = "github" + MappingMicrosoft = "microsoft" + MappingApple = "apple" + MappingWeChat = "wechat" + MappingGeneric = "generic" +) + +// User info source types +const ( + UserInfoSourceEndpoint = "endpoint" // Default: Get user info from dedicated endpoint + UserInfoSourceIDToken = "id_token" // Extract user info from ID token (JWT) + UserInfoSourceAccessToken = "access_token" // Extract user info from access token response +)