UNPKG

@lucasroll62/nuxt3-auth

Version:

An alternative module to @nuxtjs/auth

744 lines (693 loc) 20.7 kB
import { addTemplate, addServerHandler, resolvePath, installModule, defineNuxtModule, createResolver, addPluginTemplate, addImports } from '@nuxt/kit'; import { join } from 'pathe'; import { defu } from 'defu'; import { existsSync } from 'fs'; import { hash } from 'ohash'; const name = "@lucasroll62/nuxt3-auth"; const version = "1.0.1"; function assignDefaults(strategy, defaults) { Object.assign(strategy, defu(strategy, defaults)); } function addAuthorize(nuxt, strategy, useForms = false) { const clientSecret = strategy.clientSecret; const clientID = strategy.clientId; const tokenEndpoint = strategy.endpoints.token; const audience = strategy.audience; delete strategy.clientSecret; const endpoint = `/_auth/oauth/${strategy.name}/authorize`; strategy.endpoints.token = endpoint; strategy.responseType = "code"; addTemplate({ filename: "auth-addAuthorize.ts", write: true, getContents: () => authorizeMiddlewareFile({ endpoint, strategy, useForms, clientSecret, clientID, tokenEndpoint, audience }) }); addServerHandler({ handler: join(nuxt.options.buildDir, "auth-addAuthorize.ts"), middleware: true }); } function initializePasswordGrantFlow(nuxt, strategy) { const clientSecret = strategy.clientSecret; const clientId = strategy.clientId; const tokenEndpoint = strategy.endpoints.token; delete strategy.clientSecret; const endpoint = `/_auth/${strategy.name}/token`; strategy.endpoints.login.url = endpoint; strategy.endpoints.refresh.url = endpoint; addTemplate({ filename: "auth-passwordGrant.ts", write: true, getContents: () => passwordGrantMiddlewareFile({ endpoint, strategy, clientSecret, clientId, tokenEndpoint }) }); addServerHandler({ handler: join(nuxt.options.buildDir, "auth-passwordGrant.ts"), middleware: true }); } function assignAbsoluteEndpoints(strategy) { const { url, endpoints } = strategy; if (endpoints) { for (const key of Object.keys(endpoints)) { const endpoint = endpoints[key]; if (endpoint) { if (typeof endpoint === "object") { if (!endpoint.url || endpoint.url.startsWith(url)) { continue; } endpoints[key].url = url + endpoint.url; } else { if (endpoint.startsWith(url)) { continue; } endpoints[key] = url + endpoint; } } } } } function authorizeMiddlewareFile(opt) { return ` import qs from 'querystring' import bodyParser from 'body-parser' import { defineEventHandler } from 'h3' import { createInstance } from '@refactorjs/ofetch'; // Form data parser const formMiddleware = bodyParser.urlencoded({ extended: true }) const options = ${JSON.stringify(opt)} export default defineEventHandler(async (event) => { await new Promise<void>((resolve, reject) => { const next = (err?: unknown) => { if (err) { reject(err) } else { resolve(event) } } if (!event.req.url.includes(options.endpoint)) { return next() } if (event.req.method !== 'POST') { return next() } formMiddleware(event.req, event.res, () => { const { code, code_verifier: codeVerifier, redirect_uri: redirectUri = options.strategy.redirectUri, response_type: responseType = options.strategy.responseType, grant_type: grantType = options.strategy.grantType, refresh_token: refreshToken } = event.req.body // Grant type is authorization code, but code is not available if (grantType === 'authorization_code' && !code) { return next() } // Grant type is refresh token, but refresh token is not available if (grantType === 'refresh_token' && !refreshToken) { return next() } let data: qs.ParsedUrlQueryInput | string = { client_id: options.clientID, client_secret: options.clientSecret, refresh_token: refreshToken, grant_type: grantType, response_type: responseType, redirect_uri: redirectUri, audience: options.audience, code_verifier: codeVerifier, code } const headers = { Accept: 'application/json', 'Content-Type': 'application/json' } if (options.strategy.clientSecretTransport === 'authorization_header') { // @ts-ignore headers.Authorization = 'Basic ' + Buffer.from(options.clientID + ':' + options.clientSecret).toString('base64') // client_secret is transported in auth header delete data.client_secret } if (options.useForms) { data = qs.stringify(data) headers['Content-Type'] = 'application/x-www-form-urlencoded' } const $fetch = createInstance() $fetch.$post(options.tokenEndpoint, { body: data, headers }) .then((response) => { event.res.end(JSON.stringify(response)) }) .catch((error) => { event.res.statusCode = error.response.status event.res.end(JSON.stringify(error.response.data)) }) }) }) }) `; } function passwordGrantMiddlewareFile(opt) { return ` import requrl from 'requrl' import bodyParser from 'body-parser' import { defineEventHandler } from 'h3' import { createInstance } from '@refactorjs/ofetch'; // Form data parser const formMiddleware = bodyParser.json() const options = ${JSON.stringify(opt)} export default defineEventHandler(async (event) => { await new Promise<void>((resolve, reject) => { const next = (err?: unknown) => { if (err) { reject(err) } else { resolve(event) } } if (!event.req.url.includes(options.endpoint)) { return next() } if (event.req.method !== 'POST') { return next() } formMiddleware(event.req, event.res, () => { const data = event.req.body // If \`grant_type\` is not defined, set default value if (!data.grant_type) { data.grant_type = options.strategy.grantType } // If \`client_id\` is not defined, set default value if (!data.client_id) { data.grant_type = options.clientId } // Grant type is password, but username or password is not available if (data.grant_type === 'password' && (!data.username || !data.password)) { return next(new Error('Invalid username or password')) } // Grant type is refresh token, but refresh token is not available if (data.grant_type === 'refresh_token' && !data.refresh_token) { return next(new Error('Refresh token not provided')) } const $fetch = createInstance() $fetch.$post(options.tokenEndpoint, { baseURL: requrl(event.req), body: { client_id: options.clientId, client_secret: options.clientSecret, ...data }, headers: { Accept: 'application/json' } }) .then((response) => { event.res.end(JSON.stringify(response)) }) .catch((error) => { event.res.statusCode = error.response.status event.res.end(JSON.stringify(error.response.data)) }) }) }) }) `; } function auth0(nuxt, strategy) { const DEFAULTS = { scheme: "auth0", endpoints: { authorization: `https://${strategy.domain}/authorize`, userInfo: `https://${strategy.domain}/userinfo`, token: `https://${strategy.domain}/oauth/token`, logout: `https://${strategy.domain}/v2/logout` }, scope: ["openid", "profile", "email"] }; assignDefaults(strategy, DEFAULTS); } function discord(nuxt, strategy) { const DEFAULTS = { scheme: "oauth2", endpoints: { authorization: "https://discord.com/api/oauth2/authorize", token: "https://discord.com/api/oauth2/token", userInfo: "https://discord.com/api/users/@me" // logout: 'https://discord.com/api/oauth2/token/revoke' //TODO: add post method, because discord using the post method to logout }, grantType: "authorization_code", codeChallengeMethod: "S256", scope: ["identify", "email"] }; assignDefaults(strategy, DEFAULTS); addAuthorize(nuxt, strategy, true); } function facebook(nuxt, strategy) { const DEFAULTS = { scheme: "oauth2", endpoints: { authorization: "https://facebook.com/v2.12/dialog/oauth", userInfo: "https://graph.facebook.com/v2.12/me?fields=about,name,picture{url},email" }, scope: ["public_profile", "email"] }; assignDefaults(strategy, DEFAULTS); } function github(nuxt, strategy) { const DEFAULTS = { scheme: "oauth2", endpoints: { authorization: "https://github.com/login/oauth/authorize", token: "https://github.com/login/oauth/access_token", userInfo: "https://api.github.com/user" }, scope: ["user", "email"] }; assignDefaults(strategy, DEFAULTS); addAuthorize(nuxt, strategy); } function google(nuxt, strategy) { const DEFAULTS = { scheme: "oauth2", endpoints: { authorization: "https://accounts.google.com/o/oauth2/auth", userInfo: "https://www.googleapis.com/oauth2/v3/userinfo" }, scope: ["openid", "profile", "email"] }; assignDefaults(strategy, DEFAULTS); } function laravelJWT(nuxt, strategy) { const { url } = strategy; if (!url) { throw new Error("url is required for laravel jwt!"); } const DEFAULTS = { name: "laravelJWT", scheme: "laravelJWT", endpoints: { login: { url: url + "/api/auth/login" }, refresh: { url: url + "/api/auth/refresh" }, logout: { url: url + "/api/auth/logout" }, user: { url: url + "/api/auth/user" } }, token: { property: "access_token", maxAge: 3600 }, refreshToken: { property: false, data: false, maxAge: 1209600, required: false, tokenRequired: true }, user: { property: false }, clientId: false, grantType: false }; assignDefaults(strategy, DEFAULTS); assignAbsoluteEndpoints(strategy); } function isPasswordGrant(strategy) { return strategy.grantType === "password"; } function laravelPassport(nuxt, strategy) { const { url } = strategy; if (!url) { throw new Error("url is required is laravel passport!"); } const defaults = { name: "laravelPassport", token: { property: "access_token", type: "Bearer", name: "Authorization", maxAge: 60 * 60 * 24 * 365 }, refreshToken: { property: "refresh_token", data: "refresh_token", maxAge: 60 * 60 * 24 * 30 }, user: { property: false } }; let DEFAULTS; if (isPasswordGrant(strategy)) { DEFAULTS = { ...defaults, scheme: "refresh", endpoints: { token: url + "/oauth/token", login: { baseURL: "" }, refresh: { baseURL: "" }, logout: false, user: { url: url + "/api/auth/user" } }, grantType: "password" }; assignDefaults(strategy, DEFAULTS); assignAbsoluteEndpoints(strategy); initializePasswordGrantFlow(nuxt, strategy); } else { DEFAULTS = { ...defaults, scheme: "oauth2", endpoints: { authorization: url + "/oauth/authorize", token: url + "/oauth/token", userInfo: url + "/api/auth/user", logout: false }, responseType: "code", grantType: "authorization_code", scope: "*" }; assignDefaults(strategy, DEFAULTS); assignAbsoluteEndpoints(strategy); addAuthorize(nuxt, strategy); } } function laravelSanctum(nuxt, strategy) { const endpointDefaults = { credentials: "include" }; const DEFAULTS = { scheme: "cookie", name: "laravelSanctum", cookie: { name: "XSRF-TOKEN", server: nuxt.options.ssr }, endpoints: { csrf: { ...endpointDefaults, url: "/sanctum/csrf-cookie" }, login: { ...endpointDefaults, url: "/login" }, logout: { ...endpointDefaults, url: "/logout" }, user: { ...endpointDefaults, url: "/api/user" } }, user: { property: { server: false, client: false }, autoFetch: true } }; assignDefaults(strategy, DEFAULTS); if (strategy.url) { assignAbsoluteEndpoints(strategy); } } const ProviderAliases = { "laravel/jwt": "laravelJWT", "laravel/passport": "laravelPassport", "laravel/sanctum": "laravelSanctum" }; const AUTH_PROVIDERS = { __proto__: null, ProviderAliases: ProviderAliases, auth0: auth0, discord: discord, facebook: facebook, github: github, google: google, laravelJWT: laravelJWT, laravelPassport: laravelPassport, laravelSanctum: laravelSanctum }; const BuiltinSchemes = { local: "LocalScheme", cookie: "CookieScheme", oauth2: "Oauth2Scheme", openIDConnect: "OpenIDConnectScheme", refresh: "RefreshScheme", laravelJWT: "LaravelJWTScheme", auth0: "Auth0Scheme" }; async function resolveStrategies(nuxt, options) { const strategies = []; const strategyScheme = {}; for (const name of Object.keys(options.strategies)) { if (!options.strategies[name] || options.strategies[name].enabled === false) { continue; } const strategy = Object.assign({}, options.strategies[name]); if (!strategy.name) { strategy.name = name; } if (!strategy.provider) { strategy.provider = strategy.name; } const provider = await resolveProvider(strategy.provider); delete strategy.provider; if (typeof provider === "function" && !provider.getOptions) { provider(nuxt, strategy); } if (!strategy.scheme) { strategy.scheme = strategy.name; } try { const schemeImport = await resolveScheme(strategy.scheme); delete strategy.scheme; strategyScheme[strategy.name] = schemeImport; strategies.push(strategy); } catch (e) { console.error(`[Auth] Error resolving strategy ${strategy.name}: ${e}`); } } return { strategies, strategyScheme }; } async function resolveScheme(scheme) { if (typeof scheme !== "string") { return; } if (BuiltinSchemes[scheme]) { return { name: BuiltinSchemes[scheme], as: BuiltinSchemes[scheme], from: "#auth/runtime" }; } const path = await resolvePath(scheme); if (existsSync(path)) { const _path = path.replace(/\\/g, "/"); return { name: "default", as: "Scheme$" + hash({ path: _path }), from: _path }; } } async function resolveProvider(provider) { if (typeof provider === "function") { return provider; } if (typeof provider !== "string") { return; } provider = ProviderAliases[provider] || provider; if (AUTH_PROVIDERS[provider]) { return AUTH_PROVIDERS[provider]; } try { const m = await installModule(provider); return m; } catch (e) { return; } } const moduleDefaults = { // -- Enable Global Middleware -- globalMiddleware: false, enableMiddleware: true, // -- Error handling -- resetOnError: false, ignoreExceptions: false, // -- Authorization -- scopeKey: "scope", // -- Redirects -- rewriteRedirects: true, fullPathRedirect: false, redirectStrategy: "storage", watchLoggedIn: true, redirect: { login: "/login", logout: "/", home: "/", callback: "/login" }, // -- Pinia Store -- pinia: { namespace: "auth" }, // -- Cookie Store -- cookie: { prefix: "auth.", options: { path: "/" } }, // -- localStorage Store -- localStorage: { prefix: "auth." }, // -- sessionStorage Store -- sessionStorage: { prefix: "auth." }, // -- Strategies -- defaultStrategy: void 0, strategies: {} }; const getAuthDTS = () => { return `import type { Plugin } from '#app' import { Auth } from '#auth/runtime' declare const _default: Plugin<{ auth: Auth; }>; export default _default; `; }; const getAuthPlugin = (options) => { return `import { Auth, ExpiredAuthSessionError } from '#auth/runtime' import { defineNuxtPlugin, useRuntimeConfig } from '#imports' import { defu } from 'defu'; // Active schemes ${options.schemeImports.map((i) => `import { ${i.name}${i.name !== i.as ? " as " + i.as : ""} } from '${i.from}'`).join("\n")} export default defineNuxtPlugin(nuxtApp => { // Options const options = ${JSON.stringify(options.options, null, 2)} // Create a new Auth instance const auth = new Auth(nuxtApp, options) // Register strategies ${options.strategies.map((strategy) => { const scheme = options.strategyScheme[strategy.name]; const schemeOptions = JSON.stringify(strategy, null, 2); return `auth.registerStrategy('${strategy.name}', new ${scheme.as}(auth, defu(useRuntimeConfig()?.public?.auth?.strategies?.['${strategy.name}'], ${schemeOptions})))`; }).join(";\n")} nuxtApp.provide('auth', auth) return auth.init().catch(error => { if (process.client) { // Don't console log expired auth session errors. This error is common, and expected to happen. // The error happens whenever the user does an ssr request (reload/initial navigation) with an expired refresh // token. We don't want to log this as an error. if (error instanceof ExpiredAuthSessionError) { return } console.error('[ERROR] [AUTH]', error) } }) })`; }; const CONFIG_KEY = "auth"; const module = defineNuxtModule({ meta: { name, version, configKey: CONFIG_KEY, compatibility: { nuxt: "^3.0.0" } }, defaults: moduleDefaults, async setup(moduleOptions, nuxt) { const options = defu(nuxt.options.runtimeConfig[CONFIG_KEY], moduleOptions, moduleDefaults); const resolver = createResolver(import.meta.url); const { strategies, strategyScheme } = await resolveStrategies(nuxt, options); delete options.strategies; const uniqueImports = /* @__PURE__ */ new Set(); const schemeImports = Object.values(strategyScheme).filter((i) => { if (uniqueImports.has(i.as)) { return false; } uniqueImports.add(i.as); return true; }); options.defaultStrategy = options.defaultStrategy || strategies.length ? strategies[0].name : ""; if (!nuxt.options.modules.includes("@nuxt-alt/http")) { installModule("@nuxt-alt/http"); } addPluginTemplate({ getContents: () => getAuthPlugin({ options, strategies, strategyScheme, schemeImports }), filename: "auth.plugin.mjs" }); addTemplate({ getContents: () => getAuthDTS(), filename: "auth.plugin.d.ts", write: true }); addImports([ { from: resolver.resolve("runtime/composables"), name: "useAuth" } ]); const runtime = resolver.resolve("runtime"); nuxt.options.alias["#auth/runtime"] = runtime; const utils = resolver.resolve("utils"); nuxt.options.alias["#auth/utils"] = utils; const providers = resolver.resolve("providers"); nuxt.options.alias["#auth/providers"] = providers; nuxt.options.build.transpile.push(runtime, providers, utils); if (options.enableMiddleware) { nuxt.hook("app:resolve", (app) => { app.middleware.push({ name: "auth", path: resolver.resolve("runtime/core/middleware"), global: options.globalMiddleware }); }); } if (options.plugins) { options.plugins.forEach((p) => nuxt.options.plugins.push(p)); delete options.plugins; } } }); export { module as default };