From 50f8028d62804696b600d868711e5692d37b6ec7 Mon Sep 17 00:00:00 2001 From: Dhanial Rizky Wira Putra Date: Fri, 18 Oct 2024 13:25:04 +0700 Subject: [PATCH 1/5] add tiktok provider --- example.env | 6 ++ hack/test.env | 4 + internal/api/external.go | 2 + internal/api/external_tiktok_test.go | 33 +++++++ internal/api/provider/tiktok.go | 126 +++++++++++++++++++++++++++ internal/api/settings.go | 2 + internal/api/settings_test.go | 1 + internal/conf/configuration.go | 1 + 8 files changed, 175 insertions(+) create mode 100644 internal/api/external_tiktok_test.go create mode 100644 internal/api/provider/tiktok.go diff --git a/example.env b/example.env index e645c96e9c..33879851d7 100644 --- a/example.env +++ b/example.env @@ -120,6 +120,12 @@ GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" GOTRUE_EXTERNAL_NOTION_SECRET="" GOTRUE_EXTERNAL_NOTION_REDIRECT_URI="https://localhost:9999/callback" +# TikTok OAuth config +GOTRUE_EXTERNAL_TIKTOK_ENABLED="true" +GOTRUE_EXTERNAL_TIKTOK_CLIENT_ID="" +GOTRUE_EXTERNAL_TIKTOK_SECRET="" +GOTRUE_EXTERNAL_TIKTOK_REDIRECT_URI="https://localhost:9999/callback" + # Twitter OAuth1 config GOTRUE_EXTERNAL_TWITTER_ENABLED="false" GOTRUE_EXTERNAL_TWITTER_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 35e4b61c81..1c47033c06 100644 --- a/hack/test.env +++ b/hack/test.env @@ -92,6 +92,10 @@ GOTRUE_EXTERNAL_WORKOS_ENABLED=true GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_TIKTOK_ENABLED=true +GOTRUE_EXTERNAL_TIKTOK_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_TIKTOK_SECRET=testsecret +GOTRUE_EXTERNAL_TIKTOK_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_TWITCH_ENABLED=true GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 2eff891eff..31450b11a7 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -573,6 +573,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewSlackProvider(config.External.Slack, scopes) case "slack_oidc": return provider.NewSlackOIDCProvider(config.External.SlackOIDC, scopes) + case "tiktok": + return provider.NewTikTokProvider(config.External.TikTok, scopes) case "twitch": return provider.NewTwitchProvider(config.External.Twitch, scopes) case "twitter": diff --git a/internal/api/external_tiktok_test.go b/internal/api/external_tiktok_test.go new file mode 100644 index 0000000000..750ebc27b3 --- /dev/null +++ b/internal/api/external_tiktok_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" +) + +func (ts *ExternalTestSuite) TestSignupExternalTikTok() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=tiktok", 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.TikTok.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.TikTok.ClientID, q.Get("client_id")) + ts.Equal("code", q.Get("response_type")) + ts.Equal("user.info.basic,video.list", 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("tiktok", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} diff --git a/internal/api/provider/tiktok.go b/internal/api/provider/tiktok.go new file mode 100644 index 0000000000..aaa8de2fa6 --- /dev/null +++ b/internal/api/provider/tiktok.go @@ -0,0 +1,126 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultTikTokIssuerURL = "https://www.tiktok.com" +) + +type tiktokProvider struct { + *oauth2.Config + Client *http.Client +} + +type tiktokUser struct { + ID string `json:"open_id"` + UnionID string `json:"union_id"` + DisplayName string `json:"display_name"` + AvatarUrl string `json:"avatar_url"` + AvatarUrlLarge string `json:"avatar_large_url"` + ProfileDeepLink string `json:"profile_deep_link"` + Username string `json:"username"` + IsVerified string `json:"is_verified"` + FollowerCount string `json:"follower_count"` + FollowingCount string `json:"following_count"` + LikesCount string `json:"likes_count"` + VideoCount string `json:"video_count"` +} + +// NewTikTokProvider creates a TikTok account provider. +func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + apiPath := chooseHost(ext.URL, defaultTikTokIssuerURL) + + oauthScopes := []string{ + "user.info.basic", + "video.list", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + return &tiktokProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: apiPath + "/v2/oauth/authorize/", + TokenURL: apiPath + "/v2/oauth/token/", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + }, nil +} + +func (t tiktokProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return t.Exchange(context.Background(), code) +} + +func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u tiktokUser + + fields := []string{ + "open_id", + "union_id", + "display_name", + "avatar_url", + "avatar_large_url", + "profile_deep_link", + "username", + "is_verified", + "follower_count", + "following_count", + "likes_count", + "video_count", + } + params := url.Values{} + params.Add("fields", strings.Join(fields, ",")) + resp, err := t.Config.Client(ctx, tok).Get("https://open.tiktokapis.com/v2/user/info/") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { + return nil, err + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: defaultTikTokIssuerURL, + Subject: u.ID, + Name: u.DisplayName, + Picture: u.AvatarUrl, + PreferredUsername: u.Username, + UserNameKey: u.Username, + Profile: u.ProfileDeepLink, + CustomClaims: map[string]interface{}{ + "is_verified": u.IsVerified, + "union_id": u.UnionID, + "follower_count": u.FollowerCount, + "following_count": u.FollowingCount, + "likes_count": u.LikesCount, + "video_count": u.VideoCount, + }, + + // To be deprecated + AvatarURL: u.AvatarUrl, + FullName: u.DisplayName, + ProviderId: u.ID, + }, + }, nil +} diff --git a/internal/api/settings.go b/internal/api/settings.go index bc2f38692d..0b4a988650 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -23,6 +23,7 @@ type ProviderSettings struct { Slack bool `json:"slack"` SlackOIDC bool `json:"slack_oidc"` WorkOS bool `json:"workos"` + TikTok bool `json:"tiktok"` Twitch bool `json:"twitch"` Twitter bool `json:"twitter"` Email bool `json:"email"` @@ -63,6 +64,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { Spotify: config.External.Spotify.Enabled, Slack: config.External.Slack.Enabled, SlackOIDC: config.External.SlackOIDC.Enabled, + TikTok: config.External.TikTok.Enabled, Twitch: config.External.Twitch.Enabled, Twitter: config.External.Twitter.Enabled, WorkOS: config.External.WorkOS.Enabled, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 767bcf7846..e92526b963 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -43,6 +43,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.LinkedinOIDC) require.True(t, p.GitHub) require.True(t, p.GitLab) + require.True(t, p.TikTok) require.True(t, p.Twitch) require.True(t, p.WorkOS) require.True(t, p.Zoom) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index ad4e486354..d28b37cae8 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -330,6 +330,7 @@ type ProviderConfiguration struct { SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"` Twitter OAuthProviderConfiguration `json:"twitter"` Twitch OAuthProviderConfiguration `json:"twitch"` + TikTok OAuthProviderConfiguration `json:"tiktok"` VercelMarketplace OAuthProviderConfiguration `json:"vercel_marketplace" split_words:"true"` WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` From 05807be190d85575e702d588cdf40270afe8f983 Mon Sep 17 00:00:00 2001 From: Dhanial Rizky Wira Putra Date: Fri, 18 Oct 2024 16:36:14 +0700 Subject: [PATCH 2/5] enhance oauth url of tiktok --- internal/api/provider/tiktok.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/api/provider/tiktok.go b/internal/api/provider/tiktok.go index aaa8de2fa6..069962694a 100644 --- a/internal/api/provider/tiktok.go +++ b/internal/api/provider/tiktok.go @@ -41,7 +41,7 @@ func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut return nil, err } - apiPath := chooseHost(ext.URL, defaultTikTokIssuerURL) + apiPath := chooseHost(ext.URL, "www.tiktok.com") oauthScopes := []string{ "user.info.basic", @@ -57,7 +57,7 @@ func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut ClientID: ext.ClientID[0], ClientSecret: ext.Secret, Endpoint: oauth2.Endpoint{ - AuthURL: apiPath + "/v2/oauth/authorize/", + AuthURL: apiPath + "/v2/auth/authorize/", TokenURL: apiPath + "/v2/oauth/token/", }, Scopes: oauthScopes, @@ -66,6 +66,22 @@ func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut }, nil } +func (t tiktokProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { + opts := make([]oauth2.AuthCodeOption, 0, len(args)+2) + opts = append(opts, oauth2.SetAuthURLParam("client_key", t.Config.ClientID)) + opts = append(opts, oauth2.SetAuthURLParam("scope", strings.Join(t.Config.Scopes, ","))) + opts = append(opts, args...) + + authURL := t.Config.AuthCodeURL(state, opts...) + if authURL != "" { + if u, err := url.Parse(authURL); err != nil { + u.RawQuery = strings.ReplaceAll(u.RawQuery, "+", ",") + authURL = u.String() + } + } + return authURL +} + func (t tiktokProvider) GetOAuthToken(code string) (*oauth2.Token, error) { return t.Exchange(context.Background(), code) } From eba161e872c1ee6f90b6177b7b74713a006c08af Mon Sep 17 00:00:00 2001 From: Dhanial Rizky Wira Putra Date: Fri, 18 Oct 2024 20:44:13 +0700 Subject: [PATCH 3/5] integrate tiktok --- internal/api/provider/tiktok.go | 116 ++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 34 deletions(-) diff --git a/internal/api/provider/tiktok.go b/internal/api/provider/tiktok.go index 069962694a..9709ad350e 100644 --- a/internal/api/provider/tiktok.go +++ b/internal/api/provider/tiktok.go @@ -3,11 +3,14 @@ package provider import ( "context" "encoding/json" + "errors" "net/http" "net/url" + "slices" "strings" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/utilities" "golang.org/x/oauth2" ) @@ -20,19 +23,34 @@ type tiktokProvider struct { Client *http.Client } +type tiktokUserResponse struct { + Data tiktokUserData `json:"data"` + Error tiktokErrorData `json:"error"` +} +type tiktokUserData struct { + User tiktokUser `json:"user"` +} + +type tiktokErrorData struct { + Code string `json:"code"` + Message string `json:"message"` + LogID string `json:"log_id"` +} + type tiktokUser struct { ID string `json:"open_id"` UnionID string `json:"union_id"` DisplayName string `json:"display_name"` AvatarUrl string `json:"avatar_url"` AvatarUrlLarge string `json:"avatar_large_url"` + BioDescription string `json:"bio_description"` ProfileDeepLink string `json:"profile_deep_link"` Username string `json:"username"` - IsVerified string `json:"is_verified"` - FollowerCount string `json:"follower_count"` - FollowingCount string `json:"following_count"` - LikesCount string `json:"likes_count"` - VideoCount string `json:"video_count"` + IsVerified bool `json:"is_verified"` + FollowerCount int64 `json:"follower_count"` + FollowingCount int64 `json:"following_count"` + LikesCount int64 `json:"likes_count"` + VideoCount int64 `json:"video_count"` } // NewTikTokProvider creates a TikTok account provider. @@ -41,7 +59,8 @@ func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut return nil, err } - apiPath := chooseHost(ext.URL, "www.tiktok.com") + authorizePath := chooseHost(ext.URL, "www.tiktok.com") + tokenPath := chooseHost(ext.URL, "open.tiktokapis.com") oauthScopes := []string{ "user.info.basic", @@ -57,8 +76,8 @@ func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAut ClientID: ext.ClientID[0], ClientSecret: ext.Secret, Endpoint: oauth2.Endpoint{ - AuthURL: apiPath + "/v2/auth/authorize/", - TokenURL: apiPath + "/v2/oauth/token/", + AuthURL: authorizePath + "/v2/auth/authorize/", + TokenURL: tokenPath + "/v2/oauth/token/", }, Scopes: oauthScopes, RedirectURL: ext.RedirectURI, @@ -83,11 +102,13 @@ func (t tiktokProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) } func (t tiktokProvider) GetOAuthToken(code string) (*oauth2.Token, error) { - return t.Exchange(context.Background(), code) + opts := make([]oauth2.AuthCodeOption, 0, 1) + opts = append(opts, oauth2.SetAuthURLParam("client_key", t.Config.ClientID)) + return t.Exchange(context.Background(), code, opts...) } func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { - var u tiktokUser + var u tiktokUserResponse fields := []string{ "open_id", @@ -95,48 +116,75 @@ func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us "display_name", "avatar_url", "avatar_large_url", - "profile_deep_link", - "username", - "is_verified", - "follower_count", - "following_count", - "likes_count", - "video_count", + } + if slices.Contains(t.Scopes, "user.info.profile") { + fields = append(fields, []string{ + "bio_description", + "profile_deep_link", + "username", + "is_verified", + }...) + } + if slices.Contains(t.Scopes, "user.info.stats") { + fields = append(fields, []string{ + "follower_count", + "following_count", + "likes_count", + "video_count", + }...) } params := url.Values{} params.Add("fields", strings.Join(fields, ",")) - resp, err := t.Config.Client(ctx, tok).Get("https://open.tiktokapis.com/v2/user/info/") + + req, err := http.NewRequest("GET", "https://open.tiktokapis.com/v2/user/info/?"+params.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + client := &http.Client{Timeout: defaultTimeout} + resp, err := client.Do(req) if err != nil { return nil, err } - defer resp.Body.Close() + defer utilities.SafeClose(resp.Body) if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { return nil, err } + if u.Error.Code != "ok" { + return nil, errors.New(u.Error.Message) + } return &UserProvidedData{ + Emails: []Email{ + { + Email: u.Data.User.Username, + Verified: false, + Primary: false, + }, + }, Metadata: &Claims{ Issuer: defaultTikTokIssuerURL, - Subject: u.ID, - Name: u.DisplayName, - Picture: u.AvatarUrl, - PreferredUsername: u.Username, - UserNameKey: u.Username, - Profile: u.ProfileDeepLink, + Subject: u.Data.User.ID, + Name: u.Data.User.DisplayName, + Picture: u.Data.User.AvatarUrl, + PreferredUsername: u.Data.User.Username, + UserNameKey: u.Data.User.Username, + Profile: u.Data.User.ProfileDeepLink, CustomClaims: map[string]interface{}{ - "is_verified": u.IsVerified, - "union_id": u.UnionID, - "follower_count": u.FollowerCount, - "following_count": u.FollowingCount, - "likes_count": u.LikesCount, - "video_count": u.VideoCount, + "scopes": strings.Join(t.Scopes, ","), + "is_verified": u.Data.User.IsVerified, + "union_id": u.Data.User.UnionID, + "follower_count": u.Data.User.FollowerCount, + "following_count": u.Data.User.FollowingCount, + "likes_count": u.Data.User.LikesCount, + "video_count": u.Data.User.VideoCount, }, // To be deprecated - AvatarURL: u.AvatarUrl, - FullName: u.DisplayName, - ProviderId: u.ID, + AvatarURL: u.Data.User.AvatarUrl, + FullName: u.Data.User.DisplayName, + ProviderId: u.Data.User.ID, }, }, nil } From b1031d1036822018cade4eb80e2315773b8afaf9 Mon Sep 17 00:00:00 2001 From: Dhanial Rizky Wira Putra Date: Tue, 22 Oct 2024 15:29:38 +0700 Subject: [PATCH 4/5] oauth access_token to metadata --- internal/api/provider/tiktok.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/provider/tiktok.go b/internal/api/provider/tiktok.go index 9709ad350e..19c99dbbc9 100644 --- a/internal/api/provider/tiktok.go +++ b/internal/api/provider/tiktok.go @@ -158,7 +158,7 @@ func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us return &UserProvidedData{ Emails: []Email{ { - Email: u.Data.User.Username, + Email: "", Verified: false, Primary: false, }, @@ -172,6 +172,7 @@ func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us UserNameKey: u.Data.User.Username, Profile: u.Data.User.ProfileDeepLink, CustomClaims: map[string]interface{}{ + "access_token": tok, "scopes": strings.Join(t.Scopes, ","), "is_verified": u.Data.User.IsVerified, "union_id": u.Data.User.UnionID, From 1edc74ef394ac08064fa7f9168ee5ed542f43dfc Mon Sep 17 00:00:00 2001 From: Dhanial Rizky Wira Putra Date: Fri, 25 Oct 2024 11:46:34 +0700 Subject: [PATCH 5/5] remove access token to identity data custom --- internal/api/provider/tiktok.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/api/provider/tiktok.go b/internal/api/provider/tiktok.go index 19c99dbbc9..5654960df3 100644 --- a/internal/api/provider/tiktok.go +++ b/internal/api/provider/tiktok.go @@ -172,7 +172,6 @@ func (t tiktokProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us UserNameKey: u.Data.User.Username, Profile: u.Data.User.ProfileDeepLink, CustomClaims: map[string]interface{}{ - "access_token": tok, "scopes": strings.Join(t.Scopes, ","), "is_verified": u.Data.User.IsVerified, "union_id": u.Data.User.UnionID,