diff --git a/playground/.env.example b/playground/.env.example index 0ed96a6f..876c8720 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -46,3 +46,6 @@ NUXT_OAUTH_PAYPAL_CLIENT_ID= NUXT_OAUTH_PAYPAL_CLIENT_SECRET= # Steam NUXT_OAUTH_STEAM_API_KEY= +# X +NUXT_OAUTH_X_CLIENT_ID= +NUXT_OAUTH_X_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 44aca652..a1a860e9 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -113,6 +113,12 @@ const providers = computed(() => [ disabled: Boolean(user.value?.steam), icon: 'i-simple-icons-steam', }, + { + label: user.value?.x || 'X', + to: '/auth/x', + disabled: Boolean(user.value?.x), + icon: 'i-simple-icons-x', + }, ].map(p => ({ ...p, prefetch: false, diff --git a/playground/auth.d.ts b/playground/auth.d.ts index b15c0420..35f42489 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -15,6 +15,7 @@ declare module '#auth-utils' { facebook?: string paypal?: string steam?: string + x?: string } interface UserSession { diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index a1a2a048..0ecc6590 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,5 +1,8 @@ export default defineNuxtConfig({ compatibilityDate: '2024-06-17', + devServer: { + host: '127.0.0.1', + }, extends: ['@nuxt/ui-pro'], modules: [ 'nuxt-auth-utils', diff --git a/playground/server/routes/auth/x.get.ts b/playground/server/routes/auth/x.get.ts new file mode 100644 index 00000000..3d52d0ed --- /dev/null +++ b/playground/server/routes/auth/x.get.ts @@ -0,0 +1,12 @@ +export default oauth.xEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + x: user.username, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index 92b58af1..5226e1ef 100644 --- a/src/module.ts +++ b/src/module.ts @@ -141,5 +141,10 @@ export default defineNuxtModule({ runtimeConfig.oauth.steam = defu(runtimeConfig.oauth.steam, { apiKey: '', }) + // X OAuth + runtimeConfig.oauth.x = defu(runtimeConfig.oauth.x, { + clientId: '', + clientSecret: '', + }) }, }) diff --git a/src/runtime/server/lib/oauth/x.ts b/src/runtime/server/lib/oauth/x.ts new file mode 100644 index 00000000..3138fb96 --- /dev/null +++ b/src/runtime/server/lib/oauth/x.ts @@ -0,0 +1,158 @@ +import { randomUUID } from 'node:crypto' +import type { H3Event } from 'h3' +import { + eventHandler, + createError, + getQuery, + getRequestURL, + sendRedirect, +} from 'h3' +import { withQuery, parsePath } from 'ufo' +import { defu } from 'defu' +import { useRuntimeConfig } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthXConfig { + /** + * X OAuth Client ID + * @default process.env.NUXT_OAUTH_X_CLIENT_ID + */ + clientId?: string + /** + * X OAuth Client Secret + * @default process.env.NUXT_OAUTH_X_CLIENT_SECRET + */ + clientSecret?: string + /** + * X OAuth Scope + * @default [] + * @see https://developer.x.com/en/docs/authentication/oauth-2-0/user-access-token + * @example [ 'tweet.read','users.read','offline.access ], + */ + scope?: string[] + + /** + * X OAuth Authorization URL + * @default 'https://twitter.com/i/oauth2/authorize' + */ + authorizationURL?: string + + /** + * X OAuth Token URL + * @default 'https://api.twitter.com/2/oauth2/token' + */ + tokenURL?: string + + /** + * X OAuth User URL + * @default 'https://api.twitter.com/2/users/me' + */ + userURL?: string + + /** + * Extra authorization parameters to provide to the authorization URL + * @see https://developer.x.com/en/docs/authentication/oauth-2-0/user-access-token + */ + authorizationParams: Record +} + +export function xEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.x, { + authorizationURL: 'https://twitter.com/i/oauth2/authorize', + tokenURL: 'https://api.twitter.com/2/oauth2/token', + userURL: 'https://api.twitter.com/2/users/me', + authorizationParams: { + state: randomUUID(), + code_challenge: randomUUID(), + }, + }) as OAuthXConfig + const { code } = getQuery(event) + + if (!config.clientId) { + const error = createError({ + statusCode: 500, + message: 'Missing NUXT_OAUTH_X_CLIENT_ID env variables.', + }) + if (!onError) throw error + return onError(event, error) + } + + const redirectUrl = getRequestURL(event).href + if (!code) { + config.scope = config.scope || ['tweet.read', 'users.read', 'offline.access'] + // Redirect to X Oauth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + code_challenge_method: 'plain', + redirect_uri: redirectUrl, + scope: config.scope.join(' '), + ...config.authorizationParams, + }), + ) + } + + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { + grant_type: 'authorization_code', + code_verifier: config.authorizationParams.code_challenge, + redirect_uri: parsePath(redirectUrl).pathname, + code, + } + + const authCode = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64') + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tokens: any = await $fetch(config.tokenURL as string, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${authCode}`, + }, + params, + }).catch((error) => { + return { error } + }) + if (tokens.error) { + const error = createError({ + statusCode: 401, + message: `X login failed: ${ + tokens.error?.data?.error_description || 'Unknown error' + }`, + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + const accessToken = tokens.access_token + // TODO: improve typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const user: any = await $fetch( + config.userURL as string, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + query: { + 'user.fields': 'description,id,name,profile_image_url,username,verified,verified_type', + }, + }, + ).catch((error) => { + return error + }) + + return onSuccess(event, { + tokens, + user: user?.data, + }) + }) +} diff --git a/src/runtime/server/utils/oauth.ts b/src/runtime/server/utils/oauth.ts index fde21c9d..6161a23e 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -12,6 +12,7 @@ import { cognitoEventHandler } from '../lib/oauth/cognito' import { facebookEventHandler } from '../lib/oauth/facebook' import { paypalEventHandler } from '../lib/oauth/paypal' import { steamEventHandler } from '../lib/oauth/steam' +import { xEventHandler } from '../lib/oauth/x' export const oauth = { githubEventHandler, @@ -28,4 +29,5 @@ export const oauth = { facebookEventHandler, paypalEventHandler, steamEventHandler, + xEventHandler, }