Skip to content

Commit c1b1f44

Browse files
feat: add tiktok provider
* feat: add tiktok provider * docs: add tiktok * feat: add tiktok .env example * chore: remove console logs * [autofix.ci] apply automated fixes * chore: remove unused authorizationParams * chore: use new utils * fix: extends from RequestAccesTokenBody interface * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 925f688 commit c1b1f44

File tree

9 files changed

+183
-3
lines changed

9 files changed

+183
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ It can also be set using environment variables:
199199
- PayPal
200200
- Spotify
201201
- Steam
202+
- TikTok
202203
- Twitch
203204
- X (Twitter)
204205
- XSUAA

playground/.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,7 @@ NUXT_OAUTH_XSUAA_CLIENT_SECRET=
5555
NUXT_OAUTH_XSUAA_DOMAIN=
5656
# Yandex
5757
NUXT_OAUTH_YANDEX_CLIENT_ID=
58-
NUXT_OAUTH_YANDEX_CLIENT_SECRET=
58+
NUXT_OAUTH_YANDEX_CLIENT_SECRET=
59+
# TikTok
60+
NUXT_OAUTH_TIKTOK_CLIENT_KEY=
61+
NUXT_OAUTH_TIKTOK_CLIENT_SECRET=

playground/app.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ const providers = computed(() =>
132132
disabled: Boolean(user.value?.yandex),
133133
icon: 'i-gravity-ui-logo-yandex',
134134
},
135+
{
136+
label: user.value?.tiktok || 'TikTok',
137+
to: '/auth/tiktok',
138+
disabled: Boolean(user.value?.tiktok),
139+
icon: 'i-simple-icons-tiktok',
140+
},
135141
].map(p => ({
136142
...p,
137143
prefetch: false,

playground/auth.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ declare module '#auth-utils' {
1818
x?: string
1919
xsuaa?: string
2020
yandex?: string
21+
tiktok?: string
2122
}
2223

2324
interface UserSession {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default oauthTikTokEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
tiktok: user.display_name,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,11 @@ export default defineNuxtModule<ModuleOptions>({
190190
clientSecret: '',
191191
redirectURL: '',
192192
})
193+
// TikTok OAuth
194+
runtimeConfig.oauth.tiktok = defu(runtimeConfig.oauth.tiktok, {
195+
clientKey: '',
196+
clientSecret: '',
197+
redirectURL: '',
198+
})
193199
},
194200
})
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { H3Event } from 'h3'
2+
import { eventHandler, getQuery, sendRedirect } from 'h3'
3+
import { withQuery } from 'ufo'
4+
import { defu } from 'defu'
5+
import { sha256 } from 'ohash'
6+
import { handleAccessTokenErrorResponse, handleMissingConfiguration, getOAuthRedirectURL, requestAccessToken, type RequestAccessTokenBody } from '../utils'
7+
import { useRuntimeConfig, createError } from '#imports'
8+
import type { OAuthConfig } from '#auth-utils'
9+
10+
export interface OAuthTikTokConfig {
11+
/**
12+
* TikTok Client Key
13+
* @default process.env.NUXT_OAUTH_TIKTOK_CLIENT_KEY
14+
*/
15+
clientKey?: string
16+
17+
/**
18+
* TikTok OAuth Client Secret
19+
* @default process.env.NUXT_OAUTH_TIKTOK_CLIENT_SECRET
20+
*/
21+
clientSecret?: string
22+
23+
/**
24+
* TikTok OAuth Scope
25+
* @default []
26+
* @see https://developers.tiktok.com/doc/tiktok-api-scopes/
27+
* @example ['user.info.basic']
28+
*/
29+
scope?: string[]
30+
31+
/**
32+
* Use TikTok sandbox environment.
33+
* If true it will use Login Kit for Desktop, if false it will use Login Kit for Web.
34+
* This is because Login Kit for Web doesn't support localhost or IP addresses as redirect URIs.
35+
* @see https://developers.tiktok.com/doc/login-kit-web/
36+
* @see https://developers.tiktok.com/doc/login-kit-desktop/
37+
* @default import.meta.dev // true in development, false in production
38+
*/
39+
sandbox?: boolean
40+
41+
/**
42+
* TikTok OAuth Authorization URL
43+
* @default 'https://www.tiktok.com/v2/auth/authorize/'
44+
*/
45+
authorizationURL?: string
46+
47+
/**
48+
* TikTok OAuth Token URL
49+
* @default 'https://open.tiktokapis.com/v2/oauth/token/'
50+
*/
51+
tokenURL?: string
52+
53+
/**
54+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
55+
* @default process.env.NUXT_OAUTH_TIKTOK_REDIRECT_URL or current URL
56+
*/
57+
redirectURL?: string
58+
}
59+
60+
export function oauthTikTokEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthTikTokConfig>) {
61+
return eventHandler(async (event: H3Event) => {
62+
config = defu(config, useRuntimeConfig(event).oauth?.tiktok, {
63+
sandbox: import.meta.dev,
64+
authorizationURL: 'https://www.tiktok.com/v2/auth/authorize/',
65+
tokenURL: 'https://open.tiktokapis.com/v2/oauth/token/',
66+
}) as OAuthTikTokConfig
67+
const query = getQuery<{ code?: string }>(event)
68+
if (!config.clientKey || !config.clientSecret) {
69+
return handleMissingConfiguration(event, 'tiktok', ['clientKey', 'clientSecret'], onError)
70+
}
71+
const codeVerifier = 'verify'
72+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
73+
if (!query.code) {
74+
config.scope = config.scope || []
75+
if (!config.scope.includes('user.info.basic')) {
76+
config.scope.push('user.info.basic')
77+
}
78+
// Redirect to TikTok Oauth page
79+
return sendRedirect(
80+
event,
81+
withQuery(config.authorizationURL as string, {
82+
response_type: 'code',
83+
client_key: config.clientKey,
84+
redirect_uri: redirectURL,
85+
scope: config.scope.join(','),
86+
...config.sandbox
87+
? {
88+
code_verifier: codeVerifier,
89+
code_challenge: sha256(codeVerifier),
90+
code_challenge_method: 'S256' }
91+
: {},
92+
}),
93+
)
94+
}
95+
96+
interface TikTokRequestAccessTokenBody extends RequestAccessTokenBody {
97+
client_key: string
98+
code_verifier?: string
99+
}
100+
101+
const tokens = await requestAccessToken(config.tokenURL as string, {
102+
body: {
103+
grant_type: 'authorization_code',
104+
redirect_uri: redirectURL,
105+
client_key: config.clientKey,
106+
client_secret: config.clientSecret,
107+
code: query.code,
108+
...config.sandbox ? { code_verifier: codeVerifier } : {},
109+
} as TikTokRequestAccessTokenBody,
110+
},
111+
)
112+
113+
if (tokens.error) {
114+
return handleAccessTokenErrorResponse(event, 'tiktok', tokens, onError)
115+
}
116+
const accessToken = tokens.access_token
117+
118+
const userInfoFieldsByScope: Record<string, string[]> = {
119+
'user.info.basic': ['open_id', 'union_id', 'avatar_url', 'avatar_url_100', 'avatar_large_url', 'display_name'],
120+
'user.info.profile': ['bio_description', 'profile_deep_link', 'is_verified', 'username'],
121+
'user.info.stats': ['follower_count', 'following_count', 'likes_count', 'video_count'],
122+
}
123+
124+
// TODO: improve typing
125+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
126+
const userInfo: any = await $fetch(withQuery('https://open.tiktokapis.com/v2/user/info/', {
127+
fields: config.scope?.map(scope => userInfoFieldsByScope[scope]).flat().join(','),
128+
}), {
129+
headers: {
130+
Authorization: `Bearer ${accessToken}`,
131+
},
132+
})
133+
134+
const user = userInfo?.data?.user
135+
136+
if (!user) {
137+
const error = createError({
138+
statusCode: 500,
139+
message: 'Could not get TikTok user',
140+
data: tokens,
141+
})
142+
if (!onError) throw error
143+
return onError(event, error)
144+
}
145+
146+
return onSuccess(event, {
147+
tokens,
148+
user,
149+
})
150+
})
151+
}

src/runtime/server/lib/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function getOAuthRedirectURL(event: H3Event): string {
1616
*
1717
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
1818
*/
19-
interface RequestAccessTokenBody {
19+
export interface RequestAccessTokenBody {
2020
grant_type: 'authorization_code'
2121
code: string
2222
redirect_uri: string

src/runtime/types/oauth-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { H3Event, H3Error } from 'h3'
22

3-
export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'facebook' | 'github' | 'google' | 'keycloak' | 'linkedin' | 'microsoft' | 'paypal' | 'spotify' | 'steam' | 'twitch' | 'x' | 'xsuaa' | 'yandex' | (string & {})
3+
export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'facebook' | 'github' | 'google' | 'keycloak' | 'linkedin' | 'microsoft' | 'paypal' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'x' | 'xsuaa' | 'yandex' | (string & {})
44

55
export type OnError = (event: H3Event, error: H3Error) => Promise<void> | void
66

0 commit comments

Comments
 (0)