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..5654960df3 --- /dev/null +++ b/internal/api/provider/tiktok.go @@ -0,0 +1,190 @@ +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" +) + +const ( + defaultTikTokIssuerURL = "https://www.tiktok.com" +) + +type tiktokProvider struct { + *oauth2.Config + 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 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. +func NewTikTokProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + authorizePath := chooseHost(ext.URL, "www.tiktok.com") + tokenPath := chooseHost(ext.URL, "open.tiktokapis.com") + + 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: authorizePath + "/v2/auth/authorize/", + TokenURL: tokenPath + "/v2/oauth/token/", + }, + Scopes: oauthScopes, + RedirectURL: ext.RedirectURI, + }, + }, 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) { + 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 tiktokUserResponse + + fields := []string{ + "open_id", + "union_id", + "display_name", + "avatar_url", + "avatar_large_url", + } + 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, ",")) + + 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 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: "", + Verified: false, + Primary: false, + }, + }, + Metadata: &Claims{ + Issuer: defaultTikTokIssuerURL, + 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{}{ + "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.Data.User.AvatarUrl, + FullName: u.Data.User.DisplayName, + ProviderId: u.Data.User.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 5d50605239..6b235313a6 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"`