Merge pull request #1060 from trheyi/main
Refactor OAuth handling and enhance error management in Signin API
This commit is contained in:
commit
9ce3271896
4 changed files with 686 additions and 147 deletions
|
|
@ -18,27 +18,6 @@ import (
|
||||||
"github.com/yaoapp/yao/openapi/utils"
|
"github.com/yaoapp/yao/openapi/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
|
|
||||||
type OAuthAuthorizationURLResponse struct {
|
|
||||||
AuthorizationURL string `json:"authorization_url"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthCallbackResponse represents the response for OAuth callback
|
|
||||||
type OAuthCallbackResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthAuthbackRequest represents the request for OAuth callback
|
|
||||||
type OAuthAuthbackRequest struct {
|
|
||||||
Code string `json:"code" form:"code"`
|
|
||||||
State string `json:"state" form:"state"`
|
|
||||||
Provider string `json:"provider" form:"provider"`
|
|
||||||
Scope string `json:"scope,omitempty" form:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach attaches the signin handlers to the router
|
// Attach attaches the signin handlers to the router
|
||||||
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
group.GET("/signin", getConfig)
|
group.GET("/signin", getConfig)
|
||||||
|
|
@ -95,12 +74,6 @@ func authbackPrepare(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the redirect URI from the session
|
|
||||||
err = removeRedirectURI(providerID, state)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("Failed to remove redirect URI: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("code", code)
|
params.Add("code", code)
|
||||||
params.Add("state", state)
|
params.Add("state", state)
|
||||||
|
|
@ -140,8 +113,47 @@ func authback(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the state from the session
|
// Get redirect URI
|
||||||
err := removeState(providerID, sid)
|
redirectURI, err := getRedirectURI(providerID, params.State)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "Failed to get redirect URI",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider
|
||||||
|
provider, err := GetProvider(params.Locale, providerID)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: fmt.Sprintf("Failed to get provider: %v", err),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to reconstruct redirectURI: %v", err)
|
||||||
|
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "Invalid redirect URI format",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURI = newRedirectURI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the state from the session and cache
|
||||||
|
err = removeState(providerID, sid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state")
|
log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state")
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
|
|
@ -152,8 +164,23 @@ func authback(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get UserInfo
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// Respond with success
|
// Respond with success
|
||||||
response.RespondWithSuccess(c, response.StatusOK, params)
|
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{
|
||||||
|
"params": params,
|
||||||
|
"token": tokenResponse,
|
||||||
|
"provider": provider,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOAuthAuthorizationURL generates OAuth authorization URL for a provider
|
// getOAuthAuthorizationURL generates OAuth authorization URL for a provider
|
||||||
|
|
@ -173,28 +200,16 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
locale := c.Query("locale")
|
locale := c.Query("locale")
|
||||||
|
|
||||||
// Get full configuration
|
provider, err := GetProvider(locale, providerID)
|
||||||
config := GetFullConfig(locale)
|
if err != nil {
|
||||||
if config == nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
ErrorDescription: "No signin configuration found",
|
ErrorDescription: "Failed to get provider",
|
||||||
}
|
}
|
||||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the provider
|
|
||||||
var provider *Provider
|
|
||||||
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
|
|
||||||
for _, p := range config.ThirdParty.Providers {
|
|
||||||
if p.ID == providerID {
|
|
||||||
provider = p
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
|
@ -260,7 +275,7 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the state to the session for 20 minutes
|
// Save the state to the session for 20 minutes
|
||||||
err := saveState(providerID, sid, state)
|
err = saveState(providerID, sid, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResp := &response.ErrorResponse{
|
errorResp := &response.ErrorResponse{
|
||||||
Code: response.ErrInvalidRequest.Code,
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
|
@ -270,18 +285,8 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// if response mode is form_post, save the redirect URI to the session
|
// if response mode is form_post
|
||||||
if provider.ResponseMode == "form_post" {
|
if provider.ResponseMode == "form_post" {
|
||||||
err := saveRedirectURI(providerID, state, redirectURI)
|
|
||||||
if err != nil {
|
|
||||||
errorResp := &response.ErrorResponse{
|
|
||||||
Code: response.ErrInvalidRequest.Code,
|
|
||||||
ErrorDescription: "Failed to save OAuth redirect URI",
|
|
||||||
}
|
|
||||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the redirectURI to
|
// Replace the redirectURI to
|
||||||
pathname := c.Request.URL.Path + "/prepare"
|
pathname := c.Request.URL.Path + "/prepare"
|
||||||
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
||||||
|
|
@ -297,6 +302,17 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
||||||
params.Set("redirect_uri", newRedirectURI)
|
params.Set("redirect_uri", newRedirectURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save the redirect URI to the cache
|
||||||
|
err = saveRedirectURI(providerID, state, redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: "Failed to save OAuth redirect URI",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build the authorization URL
|
// Build the authorization URL
|
||||||
authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode())
|
authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode())
|
||||||
|
|
||||||
|
|
@ -385,6 +401,14 @@ func removeRedirectURI(providerID, state string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeState(providerID, sid string) error {
|
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))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the redirect URI from the cache
|
||||||
|
removeRedirectURI(providerID, state.(string))
|
||||||
return session.Global().ID(sid).Del(fmt.Sprintf("oauth_state_%s", providerID))
|
return session.Global().ID(sid).Del(fmt.Sprintf("oauth_state_%s", providerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
394
openapi/signin/provider.go
Normal file
394
openapi/signin/provider.go
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
package signin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
"github.com/yaoapp/gou/application"
|
||||||
|
"github.com/yaoapp/gou/http"
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetClientSecret gets the client secret for the provider
|
||||||
|
func (p *Provider) GetClientSecret() (string, error) {
|
||||||
|
if p.ClientSecret != "" {
|
||||||
|
return p.ClientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.ClientSecretGenerator == nil {
|
||||||
|
return "", fmt.Errorf("client secret generator not found, set client_secret or client_secret_generator at least one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the client secret using the configured generator
|
||||||
|
return p.GenerateClientSecret()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo gets the user information from the provider
|
||||||
|
func (p *Provider) GetUserInfo(accessToken string) (*OAuthUserInfoResponse, error) {
|
||||||
|
if p.Endpoints == nil {
|
||||||
|
return nil, fmt.Errorf("endpoints not found, set endpoints at least one")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateClientSecret generates client secret based on the configured generator type
|
||||||
|
func (p *Provider) GenerateClientSecret() (string, error) {
|
||||||
|
if p.ClientSecretGenerator == nil {
|
||||||
|
return "", fmt.Errorf("client secret generator not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.ClientSecretGenerator.Type {
|
||||||
|
case "JWT_ES256", "JWT_APPLE": // Apple JWT is the same as JWT_ES256
|
||||||
|
return p.generateJWTES256()
|
||||||
|
case "BASIC_CONCAT":
|
||||||
|
return p.generateBasicConcat()
|
||||||
|
case "HMAC_SHA256":
|
||||||
|
return p.generateHMACSignature()
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported client secret generator type: %s", p.ClientSecretGenerator.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateJWTES256 generates JWT client secret using ES256 algorithm
|
||||||
|
func (p *Provider) generateJWTES256() (string, error) {
|
||||||
|
gen := p.ClientSecretGenerator
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if gen.PrivateKey == "" {
|
||||||
|
return "", fmt.Errorf("private_key is required for JWT ES256 generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if gen.Header == nil {
|
||||||
|
return "", fmt.Errorf("header is required for JWT ES256 generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if gen.Payload == nil {
|
||||||
|
return "", fmt.Errorf("payload is required for JWT ES256 generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read private key
|
||||||
|
privateKey, err := p.loadPrivateKey(gen.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expiration time (already normalized during config loading)
|
||||||
|
expiresIn := time.Hour * 24 * 90 // Default 90 days
|
||||||
|
if gen.ExpiresIn != "" {
|
||||||
|
duration, err := time.ParseDuration(gen.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
// This should not happen since it's normalized during config loading
|
||||||
|
log.Error("Failed to parse normalized expires_in '%s': %v", gen.ExpiresIn, err)
|
||||||
|
// Use default duration
|
||||||
|
} else {
|
||||||
|
expiresIn = duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT token
|
||||||
|
now := time.Now()
|
||||||
|
token := jwt.New(jwt.SigningMethodES256)
|
||||||
|
|
||||||
|
// Set header claims
|
||||||
|
for key, value := range gen.Header {
|
||||||
|
token.Header[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set payload claims
|
||||||
|
claims := token.Claims.(jwt.MapClaims)
|
||||||
|
for key, value := range gen.Payload {
|
||||||
|
claims[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set standard claims
|
||||||
|
claims["iat"] = now.Unix()
|
||||||
|
claims["exp"] = now.Add(expiresIn).Unix()
|
||||||
|
|
||||||
|
// Sign the token
|
||||||
|
tokenString, err := token.SignedString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPrivateKey loads and parses the ES256 private key
|
||||||
|
func (p *Provider) loadPrivateKey(keyPath string) (*ecdsa.PrivateKey, error) {
|
||||||
|
var keyData []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if keyPath is absolute or relative to openapi/certs
|
||||||
|
if filepath.IsAbs(keyPath) {
|
||||||
|
keyData, err = os.ReadFile(keyPath)
|
||||||
|
} else {
|
||||||
|
// Try relative to openapi/certs directory
|
||||||
|
certPath := filepath.Join("openapi", "certs", keyPath)
|
||||||
|
keyData, err = application.App.Read(certPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to read private key file: %v", err)
|
||||||
|
return nil, fmt.Errorf("failed to read private key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PEM block
|
||||||
|
block, _ := pem.Decode(keyData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode PEM block")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse private key
|
||||||
|
switch block.Type {
|
||||||
|
case "EC PRIVATE KEY":
|
||||||
|
return x509.ParseECPrivateKey(block.Bytes)
|
||||||
|
case "PRIVATE KEY":
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ecKey, ok := key.(*ecdsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("not an ECDSA private key")
|
||||||
|
}
|
||||||
|
return ecKey, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateBasicConcat generates client secret by concatenating client_id and other values
|
||||||
|
func (p *Provider) generateBasicConcat() (string, error) {
|
||||||
|
gen := p.ClientSecretGenerator
|
||||||
|
|
||||||
|
// Default pattern: client_id:timestamp
|
||||||
|
parts := []string{p.ClientID}
|
||||||
|
|
||||||
|
// Add custom parts from payload
|
||||||
|
if gen.Payload != nil {
|
||||||
|
for key, value := range gen.Payload {
|
||||||
|
if key == "separator" {
|
||||||
|
continue // Skip separator key
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%v", value))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add timestamp if no custom payload
|
||||||
|
parts = append(parts, fmt.Sprintf("%d", time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get separator from payload, default to ":"
|
||||||
|
separator := ":"
|
||||||
|
if gen.Payload != nil {
|
||||||
|
if sep, ok := gen.Payload["separator"].(string); ok {
|
||||||
|
separator = sep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, separator), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateHMACSignature generates client secret using HMAC-SHA256 signature
|
||||||
|
func (p *Provider) generateHMACSignature() (string, error) {
|
||||||
|
gen := p.ClientSecretGenerator
|
||||||
|
|
||||||
|
// Get the secret key for HMAC
|
||||||
|
secretKey := ""
|
||||||
|
if gen.PrivateKey != "" {
|
||||||
|
secretKey = gen.PrivateKey
|
||||||
|
} else if gen.Payload != nil {
|
||||||
|
if key, ok := gen.Payload["secret_key"].(string); ok {
|
||||||
|
secretKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if secretKey == "" {
|
||||||
|
return "", fmt.Errorf("secret_key is required for HMAC_SHA256 generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message to sign
|
||||||
|
message := p.ClientID
|
||||||
|
if gen.Payload != nil {
|
||||||
|
if msg, ok := gen.Payload["message"].(string); ok {
|
||||||
|
message = msg
|
||||||
|
} else if msg, ok := gen.Payload["data"].(string); ok {
|
||||||
|
message = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add timestamp if configured
|
||||||
|
if gen.Payload != nil {
|
||||||
|
if addTimestamp, ok := gen.Payload["add_timestamp"].(bool); ok && addTimestamp {
|
||||||
|
message += fmt.Sprintf(":%d", time.Now().Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HMAC signature
|
||||||
|
h := hmac.New(sha256.New, []byte(secretKey))
|
||||||
|
h.Write([]byte(message))
|
||||||
|
signature := h.Sum(nil)
|
||||||
|
|
||||||
|
// Return as hex or base64 based on configuration
|
||||||
|
encoding := "hex" // default
|
||||||
|
if gen.Payload != nil {
|
||||||
|
if enc, ok := gen.Payload["encoding"].(string); ok {
|
||||||
|
encoding = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch encoding {
|
||||||
|
case "base64":
|
||||||
|
return base64.StdEncoding.EncodeToString(signature), nil
|
||||||
|
case "hex":
|
||||||
|
return hex.EncodeToString(signature), nil
|
||||||
|
default:
|
||||||
|
return hex.EncodeToString(signature), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessToken gets the access token for the provider using OAuth 2.0 authorization code flow
|
||||||
|
func (p *Provider) AccessToken(code, redirectURI string) (*OAuthTokenResponse, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("authorization code is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the access token endpoint
|
||||||
|
if p.Endpoints == nil {
|
||||||
|
return nil, fmt.Errorf("endpoints not found, set endpoints at least one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Endpoints.Token == "" {
|
||||||
|
return nil, fmt.Errorf("token endpoint not found, set token endpoint at least one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client secret (handles both ClientSecret and ClientSecretGenerator cases)
|
||||||
|
secret, err := p.GetClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get client secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the request parameters according to OAuth 2.0 spec
|
||||||
|
params := map[string]string{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": p.ClientID,
|
||||||
|
"client_secret": secret,
|
||||||
|
"redirect_uri": redirectURI,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request using gou/http package (with DNS optimization)
|
||||||
|
req := http.New(p.Endpoints.Token).
|
||||||
|
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||||
|
SetHeader("Accept", "application/json").
|
||||||
|
SetHeader("User-Agent", "Yao-OAuth-Client/1.0")
|
||||||
|
|
||||||
|
// Make the POST request
|
||||||
|
resp := req.Post(params)
|
||||||
|
if resp == nil {
|
||||||
|
return nil, fmt.Errorf("failed to make token request: no response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for HTTP errors
|
||||||
|
if resp.Code != 200 {
|
||||||
|
if resp.Data != nil {
|
||||||
|
if data, ok := resp.Data.(map[string]interface{}); ok {
|
||||||
|
if err, ok := data["error_description"]; ok {
|
||||||
|
return nil, fmt.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
if err, ok := data["error"]; ok {
|
||||||
|
return nil, fmt.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Message != "" {
|
||||||
|
return nil, fmt.Errorf("token request failed with status %d: %s", resp.Code, resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("token request failed with status %d", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON response
|
||||||
|
var tokenResponse OAuthTokenResponse
|
||||||
|
|
||||||
|
// Handle the response data - it could be already parsed JSON or raw bytes
|
||||||
|
switch data := resp.Data.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Already parsed JSON, convert to our struct
|
||||||
|
jsonBytes, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal response data: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(jsonBytes, &tokenResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response from parsed JSON: %w", err)
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
// Raw bytes, parse as JSON
|
||||||
|
if err := json.Unmarshal(data, &tokenResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response from bytes: %w", err)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
// String response, parse as JSON
|
||||||
|
if err := json.Unmarshal([]byte(data), &tokenResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token response from string: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected response data type: %T", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for OAuth error response
|
||||||
|
if tokenResponse.Error != "" {
|
||||||
|
errorMsg := tokenResponse.Error
|
||||||
|
if tokenResponse.ErrorDesc != "" {
|
||||||
|
errorMsg += ": " + tokenResponse.ErrorDesc
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("OAuth error: %s", errorMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we got an access token
|
||||||
|
if tokenResponse.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("no access token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokenResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProvider gets the provider by ID
|
||||||
|
func GetProvider(locale, providerID string) (*Provider, error) {
|
||||||
|
|
||||||
|
// Get the signin configuration
|
||||||
|
config := GetFullConfig(locale)
|
||||||
|
if config == nil {
|
||||||
|
return nil, fmt.Errorf("no signin configuration found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the provider
|
||||||
|
var provider *Provider
|
||||||
|
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
|
||||||
|
for _, p := range config.ThirdParty.Providers {
|
||||||
|
if p.ID == providerID {
|
||||||
|
provider = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider == nil {
|
||||||
|
return nil, fmt.Errorf("OAuth provider '%s' not found", providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,10 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/application"
|
"github.com/yaoapp/gou/application"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
|
|
@ -25,96 +27,6 @@ var (
|
||||||
configMutex sync.RWMutex
|
configMutex sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the signin page configuration
|
|
||||||
type Config struct {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
SuccessURL string `json:"success_url,omitempty"`
|
|
||||||
FailureURL string `json:"failure_url,omitempty"`
|
|
||||||
Form *FormConfig `json:"form,omitempty"`
|
|
||||||
Token *TokenConfig `json:"token,omitempty"`
|
|
||||||
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormConfig represents the form configuration
|
|
||||||
type FormConfig struct {
|
|
||||||
Username *UsernameConfig `json:"username,omitempty"`
|
|
||||||
Password *PasswordConfig `json:"password,omitempty"`
|
|
||||||
Captcha *CaptchaConfig `json:"captcha,omitempty"`
|
|
||||||
ForgotPasswordLink bool `json:"forgot_password_link,omitempty"`
|
|
||||||
RememberMe bool `json:"remember_me,omitempty"`
|
|
||||||
RegisterLink string `json:"register_link,omitempty"`
|
|
||||||
TermsOfServiceLink string `json:"terms_of_service_link,omitempty"`
|
|
||||||
PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsernameConfig represents the username field configuration
|
|
||||||
type UsernameConfig struct {
|
|
||||||
Placeholder string `json:"placeholder,omitempty"`
|
|
||||||
Fields []string `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PasswordConfig represents the password field configuration
|
|
||||||
type PasswordConfig struct {
|
|
||||||
Placeholder string `json:"placeholder,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaptchaConfig represents the captcha configuration
|
|
||||||
type CaptchaConfig struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Options map[string]interface{} `json:"options,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenConfig represents the token configuration
|
|
||||||
type TokenConfig struct {
|
|
||||||
ExpiresIn string `json:"expires_in,omitempty"`
|
|
||||||
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ThirdParty represents the third party login configuration
|
|
||||||
type ThirdParty struct {
|
|
||||||
Register *RegisterConfig `json:"register,omitempty"`
|
|
||||||
Providers []*Provider `json:"providers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterConfig represents the auto register configuration
|
|
||||||
type RegisterConfig struct {
|
|
||||||
Auto bool `json:"auto,omitempty"`
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretGenerator represents the client secret generator configuration
|
|
||||||
type SecretGenerator struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
ExpiresIn string `json:"expires_in,omitempty"`
|
|
||||||
PrivateKey string `json:"private_key,omitempty"`
|
|
||||||
Header map[string]interface{} `json:"header,omitempty"`
|
|
||||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Endpoints represents the OAuth endpoints
|
|
||||||
type Endpoints struct {
|
|
||||||
Authorization string `json:"authorization,omitempty"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
UserInfo string `json:"user_info,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load loads all signin configurations from the openapi directory
|
// Load loads all signin configurations from the openapi directory
|
||||||
func Load(appConfig config.Config) error {
|
func Load(appConfig config.Config) error {
|
||||||
configMutex.Lock()
|
configMutex.Lock()
|
||||||
|
|
@ -275,6 +187,19 @@ func processENVVariables(config *Config, rootPath string) {
|
||||||
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
|
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process and normalize expires_in format
|
||||||
|
if provider.ClientSecretGenerator.ExpiresIn != "" {
|
||||||
|
normalizedDuration, err := normalizeExpiresIn(provider.ClientSecretGenerator.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Invalid expires_in format '%s' for provider '%s': %v",
|
||||||
|
provider.ClientSecretGenerator.ExpiresIn, provider.ID, err)
|
||||||
|
// Set default to 90 days
|
||||||
|
provider.ClientSecretGenerator.ExpiresIn = "2160h" // 90 * 24 hours
|
||||||
|
} else {
|
||||||
|
provider.ClientSecretGenerator.ExpiresIn = normalizedDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process header values
|
// Process header values
|
||||||
if provider.ClientSecretGenerator.Header != nil {
|
if provider.ClientSecretGenerator.Header != nil {
|
||||||
for key, value := range provider.ClientSecretGenerator.Header {
|
for key, value := range provider.ClientSecretGenerator.Header {
|
||||||
|
|
@ -526,3 +451,55 @@ func GetDefaultLanguage() string {
|
||||||
}
|
}
|
||||||
return defaultLang
|
return defaultLang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeExpiresIn converts custom time units to Go standard duration format
|
||||||
|
func normalizeExpiresIn(expiresIn string) (string, error) {
|
||||||
|
if expiresIn == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as standard Go duration first
|
||||||
|
if _, err := time.ParseDuration(expiresIn); err == nil {
|
||||||
|
return expiresIn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom unit conversion patterns
|
||||||
|
patterns := map[string]func(int) string{
|
||||||
|
"d": func(n int) string { return fmt.Sprintf("%dh", n*24) }, // days to hours
|
||||||
|
"w": func(n int) string { return fmt.Sprintf("%dh", n*24*7) }, // weeks to hours
|
||||||
|
"M": func(n int) string { return fmt.Sprintf("%dh", n*24*30) }, // months to hours (approximate)
|
||||||
|
"y": func(n int) string { return fmt.Sprintf("%dh", n*24*365) }, // years to hours (approximate)
|
||||||
|
"ms": func(n int) string { return fmt.Sprintf("%dms", n) }, // milliseconds
|
||||||
|
"s": func(n int) string { return fmt.Sprintf("%ds", n) }, // seconds
|
||||||
|
"m": func(n int) string { return fmt.Sprintf("%dm", n) }, // minutes
|
||||||
|
"h": func(n int) string { return fmt.Sprintf("%dh", n) }, // hours
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract number and unit using regex
|
||||||
|
re := regexp.MustCompile(`^(\d+)(\w+)$`)
|
||||||
|
matches := re.FindStringSubmatch(expiresIn)
|
||||||
|
|
||||||
|
if len(matches) != 3 {
|
||||||
|
return "", fmt.Errorf("invalid duration format: %s", expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
number, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid number in duration: %s", matches[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := matches[2]
|
||||||
|
converter, exists := patterns[unit]
|
||||||
|
if !exists {
|
||||||
|
return "", fmt.Errorf("unsupported time unit: %s", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := converter(number)
|
||||||
|
|
||||||
|
// Validate the normalized duration
|
||||||
|
if _, err := time.ParseDuration(normalized); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create valid duration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
144
openapi/signin/types.go
Normal file
144
openapi/signin/types.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package signin
|
||||||
|
|
||||||
|
// Config represents the signin page configuration
|
||||||
|
type Config struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
SuccessURL string `json:"success_url,omitempty"`
|
||||||
|
FailureURL string `json:"failure_url,omitempty"`
|
||||||
|
Form *FormConfig `json:"form,omitempty"`
|
||||||
|
Token *TokenConfig `json:"token,omitempty"`
|
||||||
|
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormConfig represents the form configuration
|
||||||
|
type FormConfig struct {
|
||||||
|
Username *UsernameConfig `json:"username,omitempty"`
|
||||||
|
Password *PasswordConfig `json:"password,omitempty"`
|
||||||
|
Captcha *CaptchaConfig `json:"captcha,omitempty"`
|
||||||
|
ForgotPasswordLink bool `json:"forgot_password_link,omitempty"`
|
||||||
|
RememberMe bool `json:"remember_me,omitempty"`
|
||||||
|
RegisterLink string `json:"register_link,omitempty"`
|
||||||
|
TermsOfServiceLink string `json:"terms_of_service_link,omitempty"`
|
||||||
|
PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsernameConfig represents the username field configuration
|
||||||
|
type UsernameConfig struct {
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
Fields []string `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordConfig represents the password field configuration
|
||||||
|
type PasswordConfig struct {
|
||||||
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptchaConfig represents the captcha configuration
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Options map[string]interface{} `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenConfig represents the token configuration
|
||||||
|
type TokenConfig struct {
|
||||||
|
ExpiresIn string `json:"expires_in,omitempty"`
|
||||||
|
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThirdParty represents the third party login configuration
|
||||||
|
type ThirdParty struct {
|
||||||
|
Register *RegisterConfig `json:"register,omitempty"`
|
||||||
|
Providers []*Provider `json:"providers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterConfig represents the auto register configuration
|
||||||
|
type RegisterConfig struct {
|
||||||
|
Auto bool `json:"auto,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretGenerator represents the client secret generator configuration
|
||||||
|
type SecretGenerator struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
ExpiresIn string `json:"expires_in,omitempty"`
|
||||||
|
PrivateKey string `json:"private_key,omitempty"`
|
||||||
|
Header map[string]interface{} `json:"header,omitempty"`
|
||||||
|
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoints represents the OAuth endpoints
|
||||||
|
type Endpoints struct {
|
||||||
|
Authorization string `json:"authorization,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
UserInfo string `json:"user_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== API Types ====
|
||||||
|
|
||||||
|
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
|
||||||
|
type OAuthAuthorizationURLResponse struct {
|
||||||
|
AuthorizationURL string `json:"authorization_url"`
|
||||||
|
State string `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthCallbackResponse represents the response for OAuth callback
|
||||||
|
type OAuthCallbackResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthAuthbackRequest represents the request for OAuth callback
|
||||||
|
type OAuthAuthbackRequest struct {
|
||||||
|
Locale string `json:"locale" form:"locale"`
|
||||||
|
Code string `json:"code" form:"code"`
|
||||||
|
State string `json:"state" form:"state"`
|
||||||
|
Provider string `json:"provider" form:"provider"`
|
||||||
|
Scope string `json:"scope,omitempty" form:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthTokenResponse represents the response from OAuth token endpoint
|
||||||
|
type OAuthTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDesc string `json:"error_description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthTokenRequest represents the request to OAuth token endpoint
|
||||||
|
type OAuthTokenRequest struct {
|
||||||
|
GrantType string `json:"grant_type" form:"grant_type"`
|
||||||
|
Code string `json:"code" form:"code"`
|
||||||
|
ClientID string `json:"client_id" form:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret" form:"client_secret"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue