Skip to content

Commit e1e9450

Browse files
ahmedrangelatinux
andauthored
feat: add kick provider (#360)
Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Sébastien Chopin <[email protected]>
1 parent e231207 commit e1e9450

File tree

8 files changed

+162
-1
lines changed

8 files changed

+162
-1
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ It can also be set using environment variables:
216216
- Auth0
217217
- Authentik
218218
- AWS Cognito
219+
- Azure B2C
219220
- Battle.net
220221
- Bluesky (AT Protocol)
221222
- Discord
@@ -227,6 +228,7 @@ It can also be set using environment variables:
227228
- Google
228229
- Hubspot
229230
- Instagram
231+
- Kick
230232
- Keycloak
231233
- Line
232234
- Linear

playground/.env.example

+4
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ NUXT_OAUTH_APPLE_KEY_ID=
118118
NUXT_OAUTH_APPLE_TEAM_ID=
119119
NUXT_OAUTH_APPLE_CLIENT_ID=
120120
NUXT_OAUTH_APPLE_REDIRECT_URL=
121+
# Kick
122+
NUXT_OAUTH_KICK_CLIENT_ID=
123+
NUXT_OAUTH_KICK_CLIENT_SECRET=
124+
NUXT_OAUTH_KICK_REDIRECT_URL=
121125
#LiveChat
122126
NUXT_OAUTH_LIVECHAT_CLIENT_ID=
123127
NUXT_OAUTH_LIVECHAT_CLIENT_SECRET=

playground/app.vue

+6
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ const providers = computed(() =>
224224
disabled: Boolean(user.value?.apple),
225225
icon: 'i-simple-icons-apple',
226226
},
227+
{
228+
label: user.value?.kick || 'Kick',
229+
to: '/auth/kick',
230+
disabled: Boolean(user.value?.kick),
231+
icon: 'i-simple-icons-kick',
232+
},
227233
].map(p => ({
228234
...p,
229235
prefetch: false,

playground/auth.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ declare module '#auth-utils' {
3939
atlassian?: string
4040
apple?: string
4141
azureb2c?: string
42+
kick?: string
4243
}
4344

4445
interface UserSession {
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default defineOAuthKickEventHandler({
2+
async onSuccess(event, { user }) {
3+
await setUserSession(event, {
4+
user: {
5+
kick: user.email,
6+
},
7+
loggedInAt: Date.now(),
8+
})
9+
10+
return sendRedirect(event, '/')
11+
},
12+
})

src/module.ts

+6
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,12 @@ export default defineNuxtModule<ModuleOptions>({
435435
redirectURL: '',
436436
clientId: '',
437437
})
438+
// Kick OAuth
439+
runtimeConfig.oauth.kick = defu(runtimeConfig.oauth.kick, {
440+
clientId: '',
441+
clientSecret: '',
442+
redirectURL: '',
443+
})
438444
// LiveChat OAuth
439445
runtimeConfig.oauth.livechat = defu(runtimeConfig.oauth.livechat, {
440446
clientId: '',

src/runtime/server/lib/oauth/kick.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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 { randomUUID } from 'uncrypto'
6+
import { handleAccessTokenErrorResponse, handleMissingConfiguration, getOAuthRedirectURL, requestAccessToken, handlePkceVerifier } from '../utils'
7+
import { useRuntimeConfig, createError } from '#imports'
8+
import type { OAuthConfig } from '#auth-utils'
9+
10+
export interface OAuthKickConfig {
11+
/**
12+
* Kick Client ID
13+
* @default process.env.NUXT_OAUTH_KICK_CLIENT_ID
14+
*/
15+
clientId?: string
16+
17+
/**
18+
* Kick OAuth Client Secret
19+
* @default process.env.NUXT_OAUTH_KICK_CLIENT_SECRET
20+
*/
21+
clientSecret?: string
22+
23+
/**
24+
* Kick OAuth Scope
25+
* @default []
26+
* @see https://docs.kick.com/getting-started/scopes
27+
* @example ['channel:read']
28+
*/
29+
scope?: string[]
30+
31+
/**
32+
* Kick OAuth Authorization URL
33+
* @see https://docs.kick.com/getting-started/generating-tokens-oauth2-flow#authorization-endpoint
34+
* @default 'https://id.kick.com/oauth/authorize'
35+
*/
36+
authorizationURL?: string
37+
38+
/**
39+
* Kick OAuth Token URL
40+
* @see https://docs.kick.com/getting-started/generating-tokens-oauth2-flow#token-endpoint
41+
* @default 'https://id.kick.com/oauth/token'
42+
*/
43+
tokenURL?: string
44+
45+
/**
46+
* Redirect URL to to allow overriding for situations like prod failing to determine public hostname
47+
* @default process.env.NUXT_OAUTH_KICK_REDIRECT_URL or current URL
48+
*/
49+
redirectURL?: string
50+
}
51+
52+
export function defineOAuthKickEventHandler({ config, onSuccess, onError }: OAuthConfig<OAuthKickConfig>) {
53+
return eventHandler(async (event: H3Event) => {
54+
config = defu(config, useRuntimeConfig(event).oauth?.kick, {
55+
authorizationURL: 'https://id.kick.com/oauth/authorize',
56+
tokenURL: 'https://id.kick.com/oauth/token',
57+
}) as OAuthKickConfig
58+
const query = getQuery<{ code?: string }>(event)
59+
if (!config.clientId || !config.clientSecret) {
60+
return handleMissingConfiguration(event, 'kick', ['clientId', 'clientSecret'], onError)
61+
}
62+
63+
// Create pkce verifier
64+
const verifier = await handlePkceVerifier(event)
65+
66+
const redirectURL = config.redirectURL || getOAuthRedirectURL(event)
67+
68+
if (!query.code) {
69+
config.scope = config.scope || []
70+
if (!config.scope.includes('user:read'))
71+
config.scope.push('user:read')
72+
73+
// Redirect to Kick Oauth page
74+
return sendRedirect(
75+
event,
76+
withQuery(config.authorizationURL as string, {
77+
response_type: 'code',
78+
client_id: config.clientId,
79+
redirect_uri: redirectURL,
80+
scope: config.scope.join(' '),
81+
state: randomUUID(),
82+
code_challenge: verifier.code_challenge,
83+
code_challenge_method: verifier.code_challenge_method,
84+
}),
85+
)
86+
}
87+
88+
const tokens = await requestAccessToken(config.tokenURL as string, {
89+
body: {
90+
grant_type: 'authorization_code',
91+
redirect_uri: redirectURL,
92+
client_id: config.clientId,
93+
client_secret: config.clientSecret,
94+
code: query.code,
95+
code_verifier: verifier.code_verifier,
96+
},
97+
})
98+
99+
if (tokens.error) {
100+
return handleAccessTokenErrorResponse(event, 'kick', tokens, onError)
101+
}
102+
const accessToken = tokens.access_token
103+
104+
// TODO: improve typing
105+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
106+
const { data }: any = await $fetch('https://api.kick.com/public/v1/users', {
107+
headers: {
108+
Authorization: `Bearer ${accessToken}`,
109+
Accept: 'application/json',
110+
},
111+
})
112+
113+
if (!data || !data.length) {
114+
const error = createError({
115+
statusCode: 500,
116+
message: 'Could not get Kick user',
117+
data: tokens,
118+
})
119+
if (!onError) throw error
120+
return onError(event, error)
121+
}
122+
123+
const user = data[0]
124+
125+
return onSuccess(event, {
126+
tokens,
127+
user,
128+
})
129+
})
130+
}

src/runtime/types/oauth-config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { H3Event, H3Error } from 'h3'
22

33
export type ATProtoProvider = 'bluesky'
44

5-
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | (string & {})
5+
export type OAuthProvider = ATProtoProvider | 'atlassian' | 'auth0' | 'authentik' | 'azureb2c' | 'battledotnet' | 'cognito' | 'discord' | 'dropbox' | 'facebook' | 'gitea' | 'github' | 'gitlab' | 'google' | 'hubspot' | 'instagram' | 'kick' | 'keycloak' | 'line' | 'linear' | 'linkedin' | 'microsoft' | 'paypal' | 'polar' | 'spotify' | 'seznam' | 'steam' | 'strava' | 'tiktok' | 'twitch' | 'vk' | 'workos' | 'x' | 'xsuaa' | 'yandex' | 'zitadel' | 'apple' | 'livechat' | (string & {})
66

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

0 commit comments

Comments
 (0)