diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 00000000..c2431ac4 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,16 @@ +module.exports = { + root: true, + extends: [ + '@nuxt/eslint-config' + ], + rules: { + // Global + semi: ['error', 'never'], + quotes: ['error', 'single'], + 'quote-props': ['error', 'as-needed'], + // Vue + 'vue/multi-word-component-names': 0, + 'vue/max-attributes-per-line': 'off', + 'vue/no-v-html': 0 + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7a3eb22b..8920b020 100644 --- a/.gitignore +++ b/.gitignore @@ -34,12 +34,7 @@ coverage .nyc_output # VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets +.vscode # Intellij idea *.iml diff --git a/README.md b/README.md index 894becf0..515af1ed 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ It can also be set using environment variables: Supported providers: - GitHub - Spotify +- Google + +You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/). ### Example diff --git a/package.json b/package.json index f88a7090..115855d7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "@nuxt/kit": "^3.8.1", "defu": "^6.1.3", + "ofetch": "^1.3.3", "ohash": "^1.1.3" }, "devDependencies": { diff --git a/playground/.env.example b/playground/.env.example index 5646a9ed..60e761b7 100644 --- a/playground/.env.example +++ b/playground/.env.example @@ -5,3 +5,6 @@ NUXT_OAUTH_GITHUB_CLIENT_SECRET= # Spotify OAuth NUXT_OAUTH_SPOTIFY_CLIENT_ID= NUXT_OAUTH_SPOTIFY_CLIENT_SECRET= +# Google OAuth +NUXT_OAUTH_GOOGLE_CLIENT_ID= +NUXT_OAUTH_GOOGLE_CLIENT_SECRET= diff --git a/playground/app.vue b/playground/app.vue index 71105b02..77ace8db 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -25,6 +25,16 @@ const { loggedIn, session, clear } = useUserSession() > Login with Spotify + + Login with Google + ({ meta: { - name: 'auth-core', + name: 'auth-utils', configKey: 'auth' }, // Default configuration options of the Nuxt module @@ -15,13 +15,11 @@ export default defineNuxtModule({ setup (options, nuxt) { const resolver = createResolver(import.meta.url) - if (!process.env.NUXT_SESSION_PASSWORD) { + if (!process.env.NUXT_SESSION_PASSWORD && !nuxt.options._prepare) { const randomPassword = sha256(`${Date.now()}${Math.random()}`).slice(0, 32) process.env.NUXT_SESSION_PASSWORD = randomPassword - if (!nuxt.options._prepare) { - console.warn('No session password set, using a random password, please set NUXT_SESSION_PASSWORD in your .env file with at least 32 chars') - console.log(`NUXT_SESSION_PASSWORD=${randomPassword}`) - } + console.warn('No session password set, using a random password, please set NUXT_SESSION_PASSWORD in your .env file with at least 32 chars') + console.log(`NUXT_SESSION_PASSWORD=${randomPassword}`) } nuxt.options.alias['#auth-utils'] = resolver.resolve('./runtime/types/auth-utils-session') @@ -81,5 +79,10 @@ export default defineNuxtModule({ clientId: '', clientSecret: '' }) + + runtimeConfig.oauth.google = defu(runtimeConfig.oauth.google, { + clientId: '', + clientSecret: '' + }) } }) diff --git a/src/runtime/server/lib/oauth/google.ts b/src/runtime/server/lib/oauth/google.ts new file mode 100644 index 00000000..2ecea9c2 --- /dev/null +++ b/src/runtime/server/lib/oauth/google.ts @@ -0,0 +1,140 @@ +import type { H3Event, H3Error } from 'h3' +import { + eventHandler, + createError, + getQuery, + getRequestURL, + sendRedirect, +} from 'h3' +import { withQuery, parsePath } from 'ufo' +import { ofetch } from 'ofetch' +import { defu } from 'defu' +import { useRuntimeConfig } from '#imports' + +export interface OAuthGoogleConfig { + /** + * Google OAuth Client ID + * @default process.env.NUXT_OAUTH_GOOGLE_CLIENT_ID + */ + clientId?: string; + + /** + * Google OAuth Client Secret + * @default process.env.NUXT_OAUTH_GOOGLE_CLIENT_SECRET + */ + clientSecret?: string; + + /** + * Google OAuth Scope + * @default [] + * @see https://developers.google.com/identity/protocols/oauth2/scopes#google-sign-in + * @example ['email', 'openid', 'profile'] + */ + scope?: string[]; + + /** + * Google OAuth Authorization URL + * @default 'https://accounts.google.com/o/oauth2/v2/auth' + */ + authorizationURL?: string; + + /** + * Google OAuth Token URL + * @default 'https://oauth2.googleapis.com/token' + */ + tokenURL?: string; + + /** + * Redirect URL post authenticating via google + * @default '/auth/google' + */ + redirectUrl: '/auth/google'; +} + +interface OAuthConfig { + config?: OAuthGoogleConfig; + onSuccess: ( + event: H3Event, + result: { user: any; tokens: any } + ) => Promise | void; + onError?: (event: H3Event, error: H3Error) => Promise | void; +} + +export function googleEventHandler({ + config, + onSuccess, + onError, +}: OAuthConfig) { + return eventHandler(async (event: H3Event) => { + // @ts-ignore + config = defu(config, useRuntimeConfig(event).oauth?.google, { + authorizationURL: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenURL: 'https://oauth2.googleapis.com/token', + }) as OAuthGoogleConfig + const { code } = getQuery(event) + + if (!config.clientId) { + const error = createError({ + statusCode: 500, + message: 'Missing NUXT_OAUTH_GOOGLE_CLIENT_ID env variables.', + }) + if (!onError) throw error + return onError(event, error) + } + + const redirectUrl = getRequestURL(event).href + if (!code) { + config.scope = config.scope || ['email', 'profile'] + // Redirect to Google Oauth page + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + response_type: 'code', + client_id: config.clientId, + redirect_uri: redirectUrl, + scope: config.scope.join(' '), + }) + ) + } + + const body: any = { + grant_type: 'authorization_code', + redirect_uri: parsePath(redirectUrl).pathname, + client_id: config.clientId, + client_secret: config.clientSecret, + code, + } + const tokens: any = await ofetch(config.tokenURL as string, { + method: 'POST', + body, + }).catch((error) => { + return { error } + }) + if (tokens.error) { + const error = createError({ + statusCode: 401, + message: `Google login failed: ${ + tokens.error?.data?.error_description || 'Unknown error' + }`, + data: tokens, + }) + if (!onError) throw error + return onError(event, error) + } + + const accessToken = tokens.access_token + const user: any = await ofetch( + 'https://www.googleapis.com/oauth2/v3/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + return onSuccess(event, { + tokens, + user, + }) + }) +} diff --git a/src/runtime/server/utils/oauth.ts b/src/runtime/server/utils/oauth.ts index cd8389e6..6878bfa1 100644 --- a/src/runtime/server/utils/oauth.ts +++ b/src/runtime/server/utils/oauth.ts @@ -1,7 +1,9 @@ import { githubEventHandler } from '../lib/oauth/github' +import { googleEventHandler } from '../lib/oauth/google' import { spotifyEventHandler } from '../lib/oauth/spotify' export const oauth = { githubEventHandler, - spotifyEventHandler + spotifyEventHandler, + googleEventHandler } diff --git a/test/basic.test.ts b/test/basic.test.ts index c4735db8..5ce88ea5 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -10,6 +10,6 @@ describe('ssr', async () => { it('renders the index page', async () => { // Get response to a server-rendered page with `$fetch`. const html = await $fetch('/') - expect(html).toContain('
basic
') + expect(html).toContain('
Nuxt Auth Utils
') }) }) diff --git a/test/fixtures/basic/app.vue b/test/fixtures/basic/app.vue index 29a9c81f..b58e1169 100644 --- a/test/fixtures/basic/app.vue +++ b/test/fixtures/basic/app.vue @@ -1,5 +1,5 @@