feat: TikTok social provider (#397)

* feat: add TikTok social provider integration with client key support

* chore: lint

* deleted duplicate typeof

* changing verbose of the parameter clientId to client key

* chore: lint

* test: gracefully handle oauth mock server stop

* test: update generic-oauth test server port from 8080 to 8081

---------

Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
Co-authored-by: Bereket Engida <bekacru@gmail.com>
This commit is contained in:
Dewin Umana
2025-02-28 14:52:55 -06:00
committed by GitHub
parent ddb39df39f
commit 7fffd8bb90
4 changed files with 193 additions and 2 deletions

View File

@@ -65,6 +65,11 @@ export type ProviderOptions<Profile extends Record<string, any> = any> = {
* whitelisted in the provider's dashboard.
*/
redirectURI?: string;
/**
* The client key of your application
* Tiktok Social Provider uses this field instead of clientId
*/
clientKey?: string;
/**
* Disable provider from allowing users to sign in
* with this provider with an id token sent from the

View File

@@ -29,6 +29,7 @@ export async function validateAuthorizationCode({
body.set("grant_type", "authorization_code");
body.set("code", code);
codeVerifier && body.set("code_verifier", codeVerifier);
options.clientKey && body.set("client_key", options.clientKey);
deviceId && body.set("device_id", deviceId);
body.set("redirect_uri", options.redirectURI || redirectURI);
if (authentication === "basic") {

View File

@@ -11,6 +11,7 @@ import { twitter } from "./twitter";
import { dropbox } from "./dropbox";
import { linkedin } from "./linkedin";
import { gitlab } from "./gitlab";
import { tiktok } from "./tiktok";
import { reddit } from "./reddit";
import { roblox } from "./roblox";
import { z } from "zod";
@@ -28,6 +29,7 @@ export const socialProviders = {
dropbox,
linkedin,
gitlab,
tiktok,
reddit,
roblox,
vk,
@@ -38,8 +40,6 @@ export const socialProviderList = Object.keys(socialProviders) as [
...(keyof typeof socialProviders)[],
];
export type SocialProviderList = typeof socialProviderList;
export const SocialProviderListEnum = z.enum(socialProviderList, {
description: "OAuth2 provider to use",
});
@@ -66,6 +66,9 @@ export * from "./twitter";
export * from "./dropbox";
export * from "./linkedin";
export * from "./gitlab";
export * from "./tiktok";
export * from "./reddit";
export * from "./roblox";
export * from "./vk";
export type SocialProviderList = typeof socialProviderList;

View File

@@ -0,0 +1,182 @@
import { betterFetch } from "@better-fetch/fetch";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { validateAuthorizationCode } from "../oauth2";
/**
* [More info](https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info/)
*/
export interface TiktokProfile extends Record<string, any> {
data: {
user: {
/**
* The unique identification of the user in the current application.Open id
* for the client.
*
* To return this field, add `fields=open_id` in the user profile request's query parameter.
*/
open_id: string;
/**
* The unique identification of the user across different apps for the same developer.
* For example, if a partner has X number of clients,
* it will get X number of open_id for the same TikTok user,
* but one persistent union_id for the particular user.
*
* To return this field, add `fields=union_id` in the user profile request's query parameter.
*/
union_id?: string;
/**
* User's profile image.
*
* To return this field, add `fields=avatar_url` in the user profile request's query parameter.
*/
avatar_url?: string;
/**
* User`s profile image in 100x100 size.
*
* To return this field, add `fields=avatar_url_100` in the user profile request's query parameter.
*/
avatar_url_100?: string;
/**
* User's profile image with higher resolution
*
* To return this field, add `fields=avatar_url_100` in the user profile request's query parameter.
*/
avatar_large_url: string;
/**
* User's profile name
*
* To return this field, add `fields=display_name` in the user profile request's query parameter.
*/
display_name: string;
/**
* User's username.
*
* To return this field, add `fields=username` in the user profile request's query parameter.
*/
username: string;
/** @note Email is currently unsupported by TikTok */
email?: string;
/**
* User's bio description if there is a valid one.
*
* To return this field, add `fields=bio_description` in the user profile request's query parameter.
*/
bio_description?: string;
/**
* The link to user's TikTok profile page.
*
* To return this field, add `fields=profile_deep_link` in the user profile request's query parameter.
*/
profile_deep_link?: string;
/**
* Whether TikTok has provided a verified badge to the account after confirming
* that it belongs to the user it represents.
*
* To return this field, add `fields=is_verified` in the user profile request's query parameter.
*/
is_verified?: boolean;
/**
* User's followers count.
*
* To return this field, add `fields=follower_count` in the user profile request's query parameter.
*/
follower_count?: number;
/**
* The number of accounts that the user is following.
*
* To return this field, add `fields=following_count` in the user profile request's query parameter.
*/
following_count?: number;
/**
* The total number of likes received by the user across all of their videos.
*
* To return this field, add `fields=likes_count` in the user profile request's query parameter.
*/
likes_count?: number;
/**
* The total number of publicly posted videos by the user.
*
* To return this field, add `fields=video_count` in the user profile request's query parameter.
*/
video_count?: number;
};
};
error?: {
/**
* The error category in string.
*/
code?: string;
/**
* The error message in string.
*/
message?: string;
/**
* The error message in string.
*/
log_id?: string;
};
}
export interface TiktokOptions extends ProviderOptions {}
export const tiktok = (options: TiktokOptions) => {
return {
id: "tiktok",
name: "TikTok",
createAuthorizationURL({ state, scopes, redirectURI }) {
const _scopes = scopes || ["user.info.profile"];
options.scope && _scopes.push(...options.scope);
return new URL(
`https://www.tiktok.com/v2/auth/authorize?scope=${_scopes.join(
",",
)}&response_type=code&client_key=${options.clientKey}&client_secret=${
options.clientSecret
}&redirect_uri=${encodeURIComponent(
options.redirectURI || redirectURI,
)}&state=${state}`,
);
},
validateAuthorizationCode: async ({ code, redirectURI }) => {
return validateAuthorizationCode({
code,
redirectURI: options.redirectURI || redirectURI,
options,
tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/",
});
},
async getUserInfo(token) {
const fields = [
"open_id",
"avatar_large_url",
"display_name",
"username",
];
const { data: profile, error } = await betterFetch<TiktokProfile>(
`https://open.tiktokapis.com/v2/user/info/?fields=${fields.join(",")}`,
{
headers: {
authorization: `Bearer ${token.accessToken}`,
},
},
);
if (error) {
return null;
}
return {
user: {
email: profile.data.user.email || profile.data.user.username,
id: profile.data.user.open_id,
name: profile.data.user.display_name || profile.data.user.username,
image: profile.data.user.avatar_large_url,
/** @note Tiktok does not provide emailVerified or even email*/
emailVerified: profile.data.user.email ? true : false,
},
data: profile,
};
},
} satisfies OAuthProvider<TiktokProfile>;
};