diff --git a/hack/test.env b/hack/test.env index 9b80f1dc9..279258719 100644 --- a/hack/test.env +++ b/hack/test.env @@ -104,6 +104,10 @@ GOTRUE_EXTERNAL_TWITTER_ENABLED=true GOTRUE_EXTERNAL_TWITTER_CLIENT_ID=testclientid GOTRUE_EXTERNAL_TWITTER_SECRET=testsecret GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_WECHAT_ENABLED=true +GOTRUE_EXTERNAL_WECHAT_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_WECHAT_SECRET=testsecret +GOTRUE_EXTERNAL_WECHAT_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_ZOOM_ENABLED=true GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index a1c7b22fd..ebcc5ddce 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -618,6 +618,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewTwitterProvider(config.External.Twitter, scopes) case "vercel_marketplace": return provider.NewVercelMarketplaceProvider(config.External.VercelMarketplace, scopes) + case "wechat": + return provider.NewWechatProvider(config.External.Wechat) case "workos": return provider.NewWorkOSProvider(config.External.WorkOS) case "zoom": diff --git a/internal/api/external_wechat_test.go b/internal/api/external_wechat_test.go new file mode 100644 index 000000000..810e131c3 --- /dev/null +++ b/internal/api/external_wechat_test.go @@ -0,0 +1,287 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/models" +) + +func (ts *ExternalTestSuite) TestSignupExternalWechat() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=wechat", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.Wechat.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Wechat.ClientID, []string{q.Get("appid")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("snsapi_login", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("wechat", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func WechatTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, openid string, nickname string, unionid string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sns/oauth2/access_token": + *tokenCount++ + ts.Equal(code, r.URL.Query().Get("code")) + ts.Equal("authorization_code", r.URL.Query().Get("grant_type")) + ts.Equal(ts.Config.External.Wechat.ClientID[0], r.URL.Query().Get("appid")) + ts.Equal(ts.Config.External.Wechat.Secret, r.URL.Query().Get("secret")) + + w.Header().Add("Content-Type", "application/json") + tokenResp := map[string]interface{}{ + "access_token": "wechat_token", + "expires_in": 7200, + "refresh_token": "wechat_refresh_token", + "openid": openid, + "scope": "snsapi_login", + } + if unionid != "" { + tokenResp["unionid"] = unionid + } + json.NewEncoder(w).Encode(tokenResp) + + case "/sns/userinfo": + *userCount++ + ts.Equal("wechat_token", r.URL.Query().Get("access_token")) + ts.Equal(openid, r.URL.Query().Get("openid")) + + w.Header().Add("Content-Type", "application/json") + userResp := map[string]interface{}{ + "openid": openid, + "nickname": nickname, + "sex": 1, + "province": "guangdong", + "city": "shenzhen", + "country": "cn", + "headimgurl": "http://thirdwx.qlogo.cn/mmopen/test.jpg", + "privilege": []string{"chinaunicom"}, + } + if unionid != "" { + userResp["unionid"] = unionid + } + json.NewEncoder(w).Encode(userResp) + + default: + w.WriteHeader(500) + ts.Fail("unknown wechat oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Wechat.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalWechat_AuthorizationCode() { + tokenCount, userCount := 0, 0 + code := "authcode" + openid := "wechat_openid_123" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + u := performAuthorization(ts, "wechat", code, "") + + // WeChat doesn't provide email, so we use openid as the identifier + // The user should be created with openid as the primary identifier + assertWechatAuthorizationSuccess(ts, u, tokenCount, userCount, openid, nickname, "http://thirdwx.qlogo.cn/mmopen/test.jpg", unionid) +} + +func (ts *ExternalTestSuite) TestSignupExternalWechatDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + openid := "wechat_openid_123" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + u := performAuthorization(ts, "wechat", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalWechatDisableSignupSuccessWithExistingUser() { + ts.Config.DisableSignup = true + + // Create user with WeChat openid as the email field (since WeChat doesn't provide email) + openid := "wechat_openid_123" + ts.createUser(openid, openid+"@wechat.local", "wechat_test", "http://thirdwx.qlogo.cn/mmopen/test.jpg", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + u := performAuthorization(ts, "wechat", code, "") + + assertWechatAuthorizationSuccess(ts, u, tokenCount, userCount, openid, nickname, "http://thirdwx.qlogo.cn/mmopen/test.jpg", unionid) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWechatSuccessWhenMatchingToken() { + // name and avatar should be populated from WeChat API + openid := "wechat_openid_123" + ts.createUser(openid, openid+"@wechat.local", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + u := performAuthorization(ts, "wechat", code, "invite_token") + + assertWechatAuthorizationSuccess(ts, u, tokenCount, userCount, openid, nickname, "http://thirdwx.qlogo.cn/mmopen/test.jpg", unionid) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWechatErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + openid := "wechat_openid_123" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + w := performAuthorizationRequest(ts, "wechat", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalWechatErrorWhenWrongToken() { + openid := "wechat_openid_123" + ts.createUser(openid, openid+"@wechat.local", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + w := performAuthorizationRequest(ts, "wechat", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestSignupExternalWechatErrorWhenUserBanned() { + tokenCount, userCount := 0, 0 + code := "authcode" + openid := "wechat_openid_123" + nickname := "wechat_test" + unionid := "wechat_unionid_456" + server := WechatTestSignupSetup(ts, &tokenCount, &userCount, code, openid, nickname, unionid) + defer server.Close() + + u := performAuthorization(ts, "wechat", code, "") + assertWechatAuthorizationSuccess(ts, u, tokenCount, userCount, openid, nickname, "http://thirdwx.qlogo.cn/mmopen/test.jpg", unionid) + + // Find user by the generated email (openid@wechat.local) + user, err := models.FindUserByEmailAndAudience(ts.API.db, openid+"@wechat.local", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + t := time.Now().Add(24 * time.Hour) + user.BannedUntil = &t + require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until")) + + u = performAuthorization(ts, "wechat", code, "") + assertAuthorizationFailure(ts, u, "User is banned", "access_denied", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalWechatTokenError() { + tokenCount := 0 + code := "authcode" + + // Mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sns/oauth2/access_token": + tokenCount++ + w.Header().Add("Content-Type", "application/json") + errorResp := map[string]interface{}{ + "errcode": 40029, + "errmsg": "invalid code", + } + json.NewEncoder(w).Encode(errorResp) + default: + w.WriteHeader(500) + ts.Fail("unknown wechat oauth call %s", r.URL.Path) + } + })) + defer server.Close() + + ts.Config.External.Wechat.URL = server.URL + + u := performAuthorization(ts, "wechat", code, "") + + // Should get an error + v, err := url.ParseQuery(u.RawQuery) + ts.Require().NoError(err) + ts.Require().NotEmpty(v.Get("error_description")) + ts.Require().Equal("server_error", v.Get("error")) +} + +// Helper function specific to WeChat since it doesn't provide email +func assertWechatAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount int, userCount int, openid string, name string, avatar string, unionid string) { + // ensure redirect has #access_token=... + v, err := url.ParseQuery(u.RawQuery) + ts.Require().NoError(err) + ts.Require().Empty(v.Get("error_description")) + ts.Require().Empty(v.Get("error")) + + v, err = url.ParseQuery(u.Fragment) + ts.Require().NoError(err) + ts.NotEmpty(v.Get("access_token")) + ts.NotEmpty(v.Get("refresh_token")) + ts.NotEmpty(v.Get("expires_in")) + ts.Equal("bearer", v.Get("token_type")) + + ts.Equal(1, tokenCount) + if userCount > -1 { + ts.Equal(1, userCount) + } + + // For WeChat, we need to find user by the generated email (openid@wechat.local) + // since WeChat doesn't provide email addresses + email := openid + "@wechat.local" + user, err := models.FindUserByEmailAndAudience(ts.API.db, email, ts.Config.JWT.Aud) + ts.Require().NoError(err) + ts.Equal(openid, user.UserMetaData["provider_id"]) + ts.Equal(name, user.UserMetaData["full_name"]) + if avatar == "" { + ts.Equal(nil, user.UserMetaData["avatar_url"]) + } else { + ts.Equal(avatar, user.UserMetaData["avatar_url"]) + } + + // Check WeChat-specific metadata + if unionid != "" { + customClaims, ok := user.UserMetaData["custom_claims"] + ts.Require().True(ok, "custom_claims should exist in user metadata") + customClaimsMap, ok := customClaims.(map[string]interface{}) + ts.Require().True(ok, "custom_claims should be a map") + ts.Equal(unionid, customClaimsMap["unionid"]) + ts.Equal(openid, customClaimsMap["openid"]) + } +} diff --git a/internal/api/provider/wechat.go b/internal/api/provider/wechat.go new file mode 100644 index 000000000..a0d442d1e --- /dev/null +++ b/internal/api/provider/wechat.go @@ -0,0 +1,211 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultWechatAuthBase = "open.weixin.qq.com" + defaultWechatAPIBase = "api.weixin.qq.com" +) + +type wechatProvider struct { + *oauth2.Config + APIHost string +} + +type wechatUser struct { + OpenID string `json:"openid"` + Nickname string `json:"nickname"` + Sex int `json:"sex"` + Province string `json:"province"` + City string `json:"city"` + Country string `json:"country"` + HeadImgURL string `json:"headimgurl"` + Privilege []string `json:"privilege"` + UnionID string `json:"unionid"` +} + +type wechatTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + OpenID string `json:"openid"` + Scope string `json:"scope"` + UnionID string `json:"unionid"` + + // Error fields + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// NewWechatProvider creates a WeChat account provider. +func NewWechatProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + authHost := chooseHost(ext.URL, defaultWechatAuthBase) + apiHost := chooseHost(ext.URL, defaultWechatAPIBase) + + return &wechatProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("%s/connect/qrconnect", authHost), + TokenURL: fmt.Sprintf("%s/sns/oauth2/access_token", apiHost), + }, + RedirectURL: ext.RedirectURI, + Scopes: []string{"snsapi_login"}, + }, + APIHost: apiHost, + }, nil +} + +func (w wechatProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + // WeChat uses a custom token exchange that doesn't follow standard OAuth2 + // We need to make a direct HTTP request instead of using oauth2.Exchange + + // Construct the token URL, handling the case where APIHost might already include protocol + var tokenURL = fmt.Sprintf("%s/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code", + w.APIHost, w.ClientID, w.ClientSecret, code) + + resp, err := http.Get(tokenURL) + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + defer resp.Body.Close() + + var tokenResp wechatTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w", err) + } + + if tokenResp.ErrCode != 0 { + return nil, fmt.Errorf("wechat api error: %d - %s", tokenResp.ErrCode, tokenResp.ErrMsg) + } + + if tokenResp.AccessToken == "" { + return nil, fmt.Errorf("empty access token received") + } + + // Convert to standard oauth2.Token + token := &oauth2.Token{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: "Bearer", + } + + // Store additional WeChat-specific information in token extras + extras := make(map[string]interface{}) + extras["openid"] = tokenResp.OpenID + extras["scope"] = tokenResp.Scope + if tokenResp.UnionID != "" { + extras["unionid"] = tokenResp.UnionID + } + token = token.WithExtra(extras) + + return token, nil +} + +func (w wechatProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + // Extract openid from token extras + openid, ok := tok.Extra("openid").(string) + if !ok || openid == "" { + return nil, fmt.Errorf("openid not found in token") + } + + // Build user info URL + var userURL = fmt.Sprintf("%s/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN", + w.APIHost, url.QueryEscape(tok.AccessToken), url.QueryEscape(openid)) + req, err := http.NewRequestWithContext(ctx, "GET", userURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } + defer resp.Body.Close() + + var user wechatUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode user response: %w", err) + } + + if user.OpenID == "" { + return nil, fmt.Errorf("invalid user data received") + } + + // Build user data + data := &UserProvidedData{ + Metadata: &Claims{ + Issuer: fmt.Sprintf("https://%s", w.APIHost), + Subject: user.OpenID, + Name: user.Nickname, + Picture: user.HeadImgURL, + Locale: "zh_CN", + + // Custom claims for WeChat-specific data + CustomClaims: map[string]interface{}{ + "openid": user.OpenID, + "unionid": user.UnionID, + "sex": user.Sex, + "province": user.Province, + "city": user.City, + "country": user.Country, + "privilege": user.Privilege, + }, + + // Deprecated fields for backward compatibility + AvatarURL: user.HeadImgURL, + FullName: user.Nickname, + ProviderId: user.OpenID, + UserNameKey: user.Nickname, + }, + } + + // WeChat doesn't provide email, so we create a synthetic email using openid + // This ensures the user can be uniquely identified in the system + syntheticEmail := user.OpenID + "@wechat.local" + data.Emails = []Email{ + { + Email: syntheticEmail, + Verified: true, // We consider the WeChat account as verified + Primary: true, + }, + } + + return data, nil +} + +// AuthCodeURL generates the URL for WeChat OAuth authorization +func (w wechatProvider) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + // WeChat uses different parameter names and structure than standard OAuth2 + // We need to construct the URL manually since WeChat doesn't follow OAuth2 standards exactly + params := url.Values{ + "appid": {w.ClientID}, + "redirect_uri": {w.RedirectURL}, + "response_type": {"code"}, + "scope": {"snsapi_login"}, + } + + if state != "" { + params.Set("state", state) + } + + authURL := fmt.Sprintf("https://%s/connect/qrconnect?%s#wechat_redirect", + defaultWechatAuthBase, params.Encode()) + + return authURL +} diff --git a/internal/api/settings.go b/internal/api/settings.go index 7601f6f40..4931dd041 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -26,6 +26,7 @@ type ProviderSettings struct { WorkOS bool `json:"workos"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` + Wechat bool `json:"wechat"` Email bool `json:"email"` Phone bool `json:"phone"` Zoom bool `json:"zoom"` @@ -68,6 +69,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, WorkOS: config.External.WorkOS.Enabled, + Wechat: config.External.Wechat.Enabled, Email: config.External.Email.Enabled, Phone: config.External.Phone.Enabled, Zoom: config.External.Zoom.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index ca44d445b..bf7c703a6 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -46,6 +46,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.GitLab) require.True(t, p.Twitch) require.True(t, p.WorkOS) + require.True(t, p.Wechat) require.True(t, p.Zoom) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 8aff15f91..497853230 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -352,6 +352,7 @@ type ProviderConfiguration struct { Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` + Wechat OAuthProviderConfiguration `json:"wechat"` WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` Phone PhoneProviderConfiguration `json:"phone"`