Merge pull request #1061 from trheyi/main
Enhance OAuth user information handling and introduce OIDC support in…
This commit is contained in:
commit
8f77673de7
4 changed files with 1008 additions and 34 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue