diff --git a/README.md b/README.md index d8e063bd..1c7b59a8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Add Authentication to Nuxt applications with secured & sealed cookies sessions. ## Features - [Hybrid Rendering](#hybrid-rendering) support (SSR / CSR / SWR / Prerendering) -- [15+ OAuth Providers](#supported-oauth-providers) +- [20+ OAuth Providers](#supported-oauth-providers) - [Vue composable](#vue-composable) - [Server utils](#server-utils) - [`` component](#authstate-component) @@ -209,6 +209,7 @@ It can also be set using environment variables: - Steam - TikTok - Twitch +- VK - X (Twitter) - XSUAA - Yandex diff --git a/playground/.env.example b/playground/.env.example index 9f56ac54..80d469bf 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -59,6 +59,9 @@ NUXT_OAUTH_X_CLIENT_SECRET= NUXT_OAUTH_XSUAA_CLIENT_ID= NUXT_OAUTH_XSUAA_CLIENT_SECRET= NUXT_OAUTH_XSUAA_DOMAIN= +# VK +NUXT_OAUTH_VK_CLIENT_ID= +NUXT_OAUTH_VK_CLIENT_SECRET= # Yandex NUXT_OAUTH_YANDEX_CLIENT_ID= NUXT_OAUTH_YANDEX_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index dacf6476..4a0e0d80 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -138,6 +138,12 @@ const providers = computed(() => disabled: Boolean(user.value?.xsuaa), icon: 'i-simple-icons-sap', }, + { + label: user.value?.vk || 'VK', + to: '/auth/vk', + disabled: Boolean(user.value?.vk), + icon: 'i-simple-icons-vk', + }, { label: user.value?.yandex || 'Yandex', to: '/auth/yandex', diff --git a/playground/auth.d.ts b/playground/auth.d.ts index 20003028..e0855c0a 100644 --- a/playground/auth.d.ts +++ b/playground/auth.d.ts @@ -19,6 +19,7 @@ declare module '#auth-utils' { steam?: string x?: string xsuaa?: string + vk?: string yandex?: string tiktok?: string } diff --git a/playground/server/routes/auth/vk.get.ts b/playground/server/routes/auth/vk.get.ts new file mode 100644 index 00000000..59fd91a4 --- /dev/null +++ b/playground/server/routes/auth/vk.get.ts @@ -0,0 +1,12 @@ +export default oauthVKEventHandler({ + async onSuccess(event, { user }) { + await setUserSession(event, { + user: { + vk: user.user.email, + }, + loggedInAt: Date.now(), + }) + + return sendRedirect(event, '/') + }, +}) diff --git a/src/module.ts b/src/module.ts index bae913e9..93f5de18 100644 --- a/src/module.ts +++ b/src/module.ts @@ -202,6 +202,12 @@ export default defineNuxtModule({ domain: '', redirectURL: '', }) + // VK OAuth + runtimeConfig.oauth.vk = defu(runtimeConfig.oauth.vk, { + clientId: '', + clientSecret: '', + redirectURL: '', + }) // Yandex OAuth runtimeConfig.oauth.yandex = defu(runtimeConfig.oauth.yandex, { clientId: '', diff --git a/src/runtime/server/lib/oauth/vk.ts b/src/runtime/server/lib/oauth/vk.ts new file mode 100644 index 00000000..8f225a49 --- /dev/null +++ b/src/runtime/server/lib/oauth/vk.ts @@ -0,0 +1,161 @@ +import crypto from 'node:crypto' +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import { + handleMissingConfiguration, + handleAccessTokenErrorResponse, + getOAuthRedirectURL, + requestAccessToken, + type RequestAccessTokenBody, +} from '../utils' +import { useRuntimeConfig, createError } from '#imports' +import type { OAuthConfig } from '#auth-utils' + +export interface OAuthVKConfig { + /** + * VK OAuth Client ID + * @default process.env.NUXT_OAUTH_VK_CLIENT_ID + */ + clientId?: string + + /** + * VK OAuth Client Secret + * @default process.env.NUXT_OAUTH_VK_CLIENT_SECRET + */ + clientSecret?: string + + /** + * VK OAuth Scope + * @default [] + * @see https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/connection/api-integration/api-description#App-access-to-user-data + * @example ["email", "phone"] + */ + scope?: string[] + + /** + * Require email from user, adds the ['login:email'] scope if not present + * @default false + */ + emailRequired?: boolean + + /** + * VK OAuth Authorization URL + * @default 'https://id.vk.com/authorize' + */ + authorizationURL?: string + + /** + * VK OAuth Token URL + * @default 'https://id.vk.com/oauth2/auth' + */ + tokenURL?: string + + /** + * VK OAuth User URL + * @default 'https://id.vk.com/oauth2/user_info' + */ + userURL?: string + + /** + * Redirect URL to to allow overriding for situations like prod failing to determine public hostname + * @default process.env.NUXT_OAUTH_VK_REDIRECT_URL or current URL + */ + redirectURL?: string +} + +export function oauthVKEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + config = defu(config, useRuntimeConfig(event).oauth?.vk, { + authorizationURL: 'https://id.vk.com/authorize', + tokenURL: 'https://id.vk.com/oauth2/auth', + userURL: 'https://id.vk.com/oauth2/user_info', + }) as OAuthVKConfig + + const query = getQuery<{ code?: string, device_id?: string }>(event) + + if (!config.clientId || !config.clientSecret) { + return handleMissingConfiguration( + event, + 'vk', + ['clientId', 'clientSecret'], + onError, + ) + } + + const codeVerifier = 'verify' + const redirectURL = config.redirectURL || getOAuthRedirectURL(event) + + if (!query.code) { + config.scope = config.scope || [] + if (config.emailRequired && !config.scope.includes('email')) { + config.scope.push('email') + } + + // Redirect to VK Oauth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + code_challenge: crypto.createHash('sha256').update(codeVerifier).digest('base64url'), + code_challenge_method: 's256', + state: crypto.randomUUID(), + redirect_uri: redirectURL, + scope: config.scope.join(' '), + }), + ) + } + + interface VKRequestAccessTokenBody extends RequestAccessTokenBody { + code_verifier?: string + } + + const tokens = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + code: query.code as string, + code_verifier: codeVerifier, + client_id: config.clientId, + device_id: query.device_id, + redirect_uri: redirectURL, + } as VKRequestAccessTokenBody, + }) + + if (tokens.error) { + return handleAccessTokenErrorResponse(event, 'vk', tokens, onError) + } + + 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, { + method: 'POST', + body: { + access_token: accessToken, + client_id: config.clientId, + }, + }) + + if (!user) { + const error = createError({ + statusCode: 500, + message: 'Could not get VK user', + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + return onSuccess(event, { + tokens, + user, + }) + }) +} diff --git a/src/runtime/types/oauth-config.ts b/src/runtime/types/oauth-config.ts index 35bcb81a..76d495af 100644 --- a/src/runtime/types/oauth-config.ts +++ b/src/runtime/types/oauth-config.ts @@ -1,6 +1,6 @@ import type { H3Event, H3Error } from 'h3' -export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linkedin' | 'microsoft' | 'paypal' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'x' | 'xsuaa' | 'yandex' | (string & {}) +export type OAuthProvider = 'auth0' | 'battledotnet' | 'cognito' | 'discord' | 'facebook' | 'github' | 'gitlab' | 'google' | 'instagram' | 'keycloak' | 'linkedin' | 'microsoft' | 'paypal' | 'spotify' | 'steam' | 'tiktok' | 'twitch' | 'vk' | 'x' | 'xsuaa' | 'yandex' | (string & {}) export type OnError = (event: H3Event, error: H3Error) => Promise | void