├── .eslintignore ├── src ├── runtime │ ├── schemes │ │ ├── scheme.ts │ │ ├── index.ts │ │ └── token │ │ │ ├── types.ts │ │ │ └── index.ts │ ├── types │ │ ├── user.ts │ │ ├── index.ts │ │ ├── request.ts │ │ └── storage.ts │ ├── index.ts │ ├── composables │ │ └── use-auth.ts │ ├── templates │ │ └── plugin.mjs │ ├── core │ │ ├── middleware.ts │ │ ├── fingerprint.ts │ │ ├── storage.ts │ │ ├── request.ts │ │ └── auth.ts │ └── utils │ │ └── index.ts ├── module.d.ts ├── options.ts └── module.ts ├── tsconfig.json ├── playground ├── package.json ├── pages │ ├── index.vue │ └── auth │ │ └── login.vue └── nuxt.config.ts ├── .editorconfig ├── .eslintrc ├── .gitignore ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /src/runtime/schemes/scheme.ts: -------------------------------------------------------------------------------- 1 | export class Scheme { 2 | } 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/runtime/schemes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scheme'; 2 | export * from './token'; 3 | -------------------------------------------------------------------------------- /src/runtime/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | [key: string]: any; 3 | }; 4 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground" 4 | } -------------------------------------------------------------------------------- /src/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './schemes'; 3 | export * from './core/auth'; 4 | -------------------------------------------------------------------------------- /src/runtime/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage'; 2 | export * from './user'; 3 | export * from './request'; 4 | -------------------------------------------------------------------------------- /src/runtime/types/request.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | export type SanctumAuthResponse = AxiosResponse; 4 | -------------------------------------------------------------------------------- /src/runtime/composables/use-auth.ts: -------------------------------------------------------------------------------- 1 | import { useNuxtApp } from '#app'; 2 | import { Auth } from '../core/auth'; 3 | 4 | export const useAuth = () => { 5 | const { $auth } = useNuxtApp(); 6 | 7 | return $auth as Auth; 8 | }; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs/eslint-config-typescript" 4 | ], 5 | "rules": { 6 | "@typescript-eslint/no-unused-vars": [ 7 | "off" 8 | ], 9 | "no-useless-constructor":"off", 10 | "semi": ["error", "always"], 11 | "indent": ["error", 2], 12 | "indent-legacy": ["error", 2] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/runtime/templates/plugin.mjs: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#imports'; 2 | import { Auth } from '#sanctum/runtime/core/auth'; 3 | 4 | export default defineNuxtPlugin(async (nuxtApp) => { 5 | const options = JSON.parse('<%= JSON.stringify(options) %>'); 6 | 7 | const auth = new Auth(nuxtApp, options); 8 | 9 | await auth.run(); 10 | 11 | return { 12 | provide: { 13 | auth 14 | } 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/module.d.ts: -------------------------------------------------------------------------------- 1 | import type { Auth } from './runtime'; 2 | import type { ModuleOptions } from './options'; 3 | import type { NuxtModule } from '@nuxt/schema'; 4 | 5 | declare const module: NuxtModule; 6 | 7 | declare module '@nuxt/schema' { 8 | interface NuxtConfig { 9 | sanctum?: ModuleOptions 10 | } 11 | } 12 | 13 | declare module '@vue/runtime-core' { 14 | interface ComponentCustomProperties { 15 | $auth: Auth; 16 | } 17 | } 18 | 19 | declare module "#app" { 20 | interface NuxtApp { 21 | $auth: Auth; 22 | } 23 | } 24 | 25 | export { module as default, ModuleOptions } ; 26 | -------------------------------------------------------------------------------- /src/runtime/schemes/token/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | 3 | export interface TokenSchemeOptions { 4 | endpoints?: { 5 | login: AxiosRequestConfig; 6 | user: AxiosRequestConfig; 7 | refresh?: AxiosRequestConfig | false; 8 | logout?: AxiosRequestConfig | false; 9 | }, 10 | user?: { 11 | property?: string | false; 12 | }, 13 | token: { 14 | prefix?: string | false; 15 | property: string; 16 | headerName?: string; 17 | expiredAtProperty?: string | false; 18 | }, 19 | refreshToken?: { 20 | property: string | false; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .vercel_build_output 23 | .build-* 24 | .env 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode 38 | 39 | # Intellij idea 40 | *.iml 41 | .idea 42 | 43 | # OSX 44 | .DS_Store 45 | .AppleDouble 46 | .LSOverride 47 | .AppleDB 48 | .AppleDesktop 49 | Network Trash Folder 50 | Temporary Items 51 | .apdisk 52 | -------------------------------------------------------------------------------- /src/runtime/types/storage.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'pinia'; 2 | import { User } from './user'; 3 | 4 | export interface StorageOptions { 5 | user: User | null; 6 | loggedIn: boolean; 7 | token: string | null; 8 | expired_at?: Date | null; 9 | fingerprint?: string | null; 10 | ip: string; 11 | }; 12 | 13 | export interface StorageActions { 14 | setUser: (user: User | null) => void; 15 | setLoggedIn: (status: boolean) => void; 16 | setToken: (token?: string | null) => void; 17 | setExpiredAt: (expired_at?: string | Date) => void; 18 | setFingerprint: (fingerprint: string) => void; 19 | setIPAddress: (ip: string) => void; 20 | }; 21 | 22 | export type AuthStore = Store; 23 | -------------------------------------------------------------------------------- /playground/pages/auth/login.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 33 | -------------------------------------------------------------------------------- /src/runtime/core/middleware.ts: -------------------------------------------------------------------------------- 1 | import { routeOption } from '../utils'; 2 | import { useAuth } from '../composables/use-auth'; 3 | import { defineNuxtRouteMiddleware, navigateTo } from '#imports'; 4 | 5 | const middleware = defineNuxtRouteMiddleware(async (to) => { 6 | const auth = useAuth(); 7 | 8 | if (!auth) { 9 | return; 10 | } 11 | 12 | const guestMode = routeOption(to, 'auth', ['guest', false]); 13 | 14 | if (auth.loggedIn) { 15 | const { tokenExpired } = auth.scheme.check(); 16 | 17 | if (tokenExpired) { 18 | try { 19 | await auth.scheme.refreshToken(); 20 | } catch { 21 | auth.scheme.reset(); 22 | return navigateTo(auth.getRedirectRoute('toLogin')); 23 | } 24 | } 25 | 26 | if (guestMode) { 27 | return navigateTo(auth.getRedirectRoute('home')); 28 | } 29 | } else if (!guestMode) { 30 | return navigateTo(auth.getRedirectRoute('toLogin')); 31 | } 32 | }); 33 | 34 | export { middleware as AuthMiddleware, middleware as default }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) SKYROSES & Plenexy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosRequestConfig } from 'axios'; 2 | import { TokenSchemeOptions } from './runtime'; 3 | 4 | export interface ModuleOptions { 5 | baseURL?: string; 6 | globalMiddleware?: boolean; 7 | fingerprint?: { 8 | enabled?: boolean; 9 | property?: string; 10 | ipService?: { 11 | endpoint: AxiosRequestConfig; 12 | property?: string | false; 13 | } 14 | }; 15 | pinia?: { 16 | namespace?: string; 17 | }; 18 | redirects: { 19 | home: string; 20 | toLogin: string; 21 | afterLogin: string; 22 | afterLogout: string; 23 | }, 24 | tokenScheme: TokenSchemeOptions; 25 | }; 26 | 27 | export const defaultOptions: ModuleOptions = { 28 | globalMiddleware: true, 29 | tokenScheme: { 30 | token: { 31 | headerName: 'Authorization', 32 | property: 'access_token' 33 | } 34 | }, 35 | fingerprint: { 36 | enabled: true, 37 | property: 'fingerprint' 38 | }, 39 | pinia: { 40 | namespace: 'auth' 41 | }, 42 | redirects: { 43 | home: '/', 44 | toLogin: '/auth/login', 45 | afterLogin: '/', 46 | afterLogout: '/' 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@plenexy/nuxt-sanctum", 3 | "version": "2.3.0", 4 | "author": "skyroses", 5 | "license": "MIT", 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/skyroses/nuxt-sanctum.git" 10 | }, 11 | "exports": { 12 | ".": { 13 | "import": "./dist/module.mjs", 14 | "require": "./dist/module.cjs" 15 | } 16 | }, 17 | "main": "./dist/module.cjs", 18 | "types": "./dist/module.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "prepack": "unbuild", 24 | "dev": "nuxi dev playground", 25 | "dev:build": "nuxi build playground", 26 | "dev:prepare": "nuxt-module-build --stub && nuxi prepare playground", 27 | "test": "echo \"No test specified\"" 28 | }, 29 | "dependencies": { 30 | "@nuxt/kit": "^3.0.0", 31 | "@nuxtjs-alt/axios": "^1.1.0", 32 | "@pinia/nuxt": "^0.4.6", 33 | "axios": "^1.2.1", 34 | "h3": "^1.0.1", 35 | "pinia": "^2.0.27", 36 | "requrl": "^3.0.2", 37 | "vue-router": "^4.1.6" 38 | }, 39 | "devDependencies": { 40 | "@nuxt/module-builder": "latest", 41 | "@nuxtjs/eslint-config-typescript": "latest", 42 | "eslint": "latest", 43 | "nuxt": "^3.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from 'nuxt'; 2 | import MyModule from '..'; 3 | 4 | export default defineNuxtConfig({ 5 | modules: [ 6 | MyModule, 7 | '@nuxtjs-alt/axios', 8 | '@pinia/nuxt' 9 | ], 10 | axios: { 11 | baseURL: 'http://api.test', 12 | credentials: true 13 | }, 14 | sanctum: { 15 | redirects: { 16 | afterLogin: '/', 17 | afterLogout: '/auth/login', 18 | toLogin: '/auth/login', 19 | home: '/' 20 | }, 21 | tokenScheme: { 22 | endpoints: { 23 | login: { 24 | url: '/api/auth/users/login', 25 | method: 'post' 26 | }, 27 | logout: { 28 | url: '/api/auth/logout', 29 | method: 'post' 30 | }, 31 | user: { 32 | url: '/api/auth/me', 33 | method: 'post' 34 | }, 35 | refresh: { 36 | url: '/api/auth/refresh-tokens', 37 | method: 'post' 38 | } 39 | }, 40 | user: { 41 | property: false 42 | }, 43 | token: { 44 | property: 'access_token', 45 | headerName: 'Authorization', 46 | expiredAtProperty: 'expired_at' 47 | }, 48 | refreshToken: { 49 | property: 'refresh_token' 50 | } 51 | } 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, addPluginTemplate, createResolver, addImports } from '@nuxt/kit'; 2 | import { defaultOptions, ModuleOptions } from './options'; 3 | 4 | export const moduleName = '@plenexy/nuxt-sanctum'; 5 | 6 | export default defineNuxtModule({ 7 | meta: { 8 | name: moduleName, 9 | configKey: 'sanctum', 10 | compatibility: { 11 | nuxt: '^3.0.0-rc.9' 12 | } 13 | }, 14 | defaults: defaultOptions, 15 | setup (_options, nuxt) { 16 | const options: ModuleOptions = { 17 | ...defaultOptions, 18 | ..._options 19 | }; 20 | 21 | const resolver = createResolver(import.meta.url); 22 | 23 | nuxt.options.build.transpile.push(resolver.resolve('runtime')); 24 | nuxt.options.alias['#sanctum/runtime'] = resolver.resolve('runtime'); 25 | 26 | addImports([ 27 | { 28 | from: resolver.resolve('runtime/composables/use-auth'), name: 'useAuth' 29 | } 30 | ]); 31 | 32 | addPluginTemplate({ 33 | src: resolver.resolve('runtime/templates/plugin.mjs'), 34 | options: { 35 | ...options 36 | } 37 | }); 38 | 39 | nuxt.hook('app:resolve', (app) => { 40 | app.middleware.push({ 41 | name: 'auth', 42 | path: resolver.resolve('runtime/core/middleware'), 43 | global: options.globalMiddleware 44 | }); 45 | }); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/runtime/core/fingerprint.ts: -------------------------------------------------------------------------------- 1 | import { getProp, isValidIP, sha256 } from '../utils'; 2 | import { Auth } from './auth'; 3 | 4 | export class Fingerprint { 5 | constructor ( 6 | private auth: Auth 7 | ) { } 8 | 9 | async generate (): Promise { 10 | if (process.server) { 11 | return this.server(); 12 | } 13 | 14 | return await this.client(); 15 | } 16 | 17 | private async client () { 18 | if (!this.auth.options.fingerprint?.ipService) { 19 | return null; 20 | } 21 | 22 | const options = this.auth.options.fingerprint.ipService; 23 | 24 | const { data } = await this.auth.request(options.endpoint); 25 | 26 | const userAgent = navigator.userAgent; 27 | const ip = getProp(data, options.property) as string; 28 | 29 | if (!userAgent) { 30 | return null; 31 | } 32 | 33 | if (!ip && !isValidIP(ip)) { 34 | return null; 35 | } 36 | 37 | return this.hash(ip, userAgent); 38 | } 39 | 40 | private server () { 41 | const userAgent = this.auth.req.headers['user-agent']; 42 | const ip = String(this.auth.req.headers['x-forwarded-for'] || this.auth.req.socket.remoteAddress); 43 | 44 | if (!userAgent) { 45 | return null; 46 | } 47 | 48 | if (!ip && !isValidIP(ip)) { 49 | return null; 50 | } 51 | 52 | return this.hash(ip, userAgent); 53 | } 54 | 55 | private hash (ip: string, userAgent: string) { 56 | return sha256([ 57 | ip, 58 | userAgent 59 | ].join('|')); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/runtime/core/storage.ts: -------------------------------------------------------------------------------- 1 | import { NuxtApp } from '#app/nuxt'; 2 | import { defineStore, Pinia } from 'pinia'; 3 | import { ModuleOptions } from '../../options'; 4 | import { AuthStore } from '..'; 5 | import { User } from '../types'; 6 | 7 | export class Storage { 8 | public store: AuthStore; 9 | 10 | // eslint-disable-next-line no-useless-constructor 11 | constructor ( 12 | protected nuxt: NuxtApp, 13 | protected options: ModuleOptions & { moduleName: string } 14 | ) {} 15 | 16 | initStore () { 17 | // @ts-ignore 18 | const pinia: Pinia = this.nuxt.$pinia; 19 | 20 | this.store = defineStore(String(this.options.pinia!.namespace), { 21 | state: () => ({ 22 | user: null, 23 | loggedIn: false, 24 | token: null, 25 | expired_at: null, 26 | fingerprint: null, 27 | ip: '' 28 | }), 29 | actions: { 30 | setUser (user: User | null) { 31 | this.user = user; 32 | this.loggedIn = !!user; 33 | }, 34 | setLoggedIn (status: boolean) { 35 | this.loggedIn = status; 36 | }, 37 | setToken (token?: string | null) { 38 | this.token = token; 39 | }, 40 | setExpiredAt (expiredAt?: string | Date | null) { 41 | if (expiredAt instanceof String) { 42 | this.expired_at = new Date(expiredAt); 43 | return; 44 | } 45 | 46 | this.expired_at = expiredAt; 47 | }, 48 | setFingerprint (fingerprint: string | null) { 49 | this.fingerprint = fingerprint; 50 | }, 51 | setIPAddress (ip: string) { 52 | this.ip = ip; 53 | } 54 | } 55 | })(pinia); 56 | 57 | return this; 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /src/runtime/utils/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { RouteLocationNormalized } from 'vue-router'; 3 | 4 | /** 5 | * Get property defined by dot notation in string. 6 | * Based on https://github.com/dy/dotprop (MIT) 7 | * 8 | * @param {Object} holder Target object where to look property up 9 | * @param {string} propName Dot notation, like 'this.a.b.c' 10 | * @return {*} A property value 11 | */ 12 | export function getProp ( 13 | holder: Record, 14 | propName?: string | false 15 | ): unknown { 16 | if (!propName || !holder || typeof holder !== 'object') { 17 | return holder; 18 | } 19 | 20 | if (propName in holder) { 21 | return holder[propName]; 22 | } 23 | 24 | const propParts = Array.isArray(propName) 25 | ? propName 26 | : (propName + '').split('.'); 27 | 28 | let result: any = holder; 29 | while (propParts.length && result) { 30 | result = result[propParts.shift()]; 31 | } 32 | 33 | return result; 34 | } 35 | 36 | /** 37 | * 38 | * @param {RouteLocationNormalized} route 39 | * @param {string} key 40 | * @param {any} value 41 | * @return boolean 42 | */ 43 | export function routeOption ( 44 | route: RouteLocationNormalized, 45 | key: string, 46 | value: any 47 | ): boolean { 48 | return route.matched.some((m) => { 49 | if (value instanceof Array) { 50 | for (const iter of value) { 51 | if (m.meta[key] === iter) { 52 | return true; 53 | } 54 | } 55 | } 56 | 57 | return m.meta[key] === value; 58 | }); 59 | } 60 | 61 | /** 62 | * 63 | * @param {string} value 64 | * @return {string} 65 | */ 66 | export function sha256 (value: string): string { 67 | return crypto.createHash('sha256').update(value).digest('hex'); 68 | } 69 | 70 | /** 71 | * 72 | * @param {string} str 73 | * @returns {boolean} 74 | */ 75 | export function isValidIP (str: string): boolean { 76 | // Regular expression to check if string is a IP address 77 | const regexExp = /^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/gi; 78 | 79 | return regexExp.test(str); 80 | } 81 | 82 | export function tryParseJSON (jsonString: any) { 83 | try { 84 | const o = JSON.parse(jsonString); 85 | 86 | if (o && typeof o === 'object') { 87 | return o; 88 | } 89 | } catch (e) { } 90 | 91 | return jsonString; 92 | }; 93 | -------------------------------------------------------------------------------- /src/runtime/core/request.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosRequestConfig } from 'axios'; 2 | import { SanctumAuthResponse } from '../types'; 3 | import { getProp, tryParseJSON } from '../utils'; 4 | import { Auth } from './auth'; 5 | 6 | const RETRY_REQUEST_HEADER = 'X-Nuxt-Sanctum-Retry'; 7 | 8 | export class RequestHandler { 9 | urls: string[]; 10 | 11 | constructor ( 12 | protected auth: Auth 13 | ) { 14 | this.auth.axios.interceptors.request.use(this.onRequest.bind(this)); 15 | this.auth.axios.interceptors.response.use(undefined, this.onError.bind(this)); 16 | } 17 | 18 | onRequest (config: AxiosRequestConfig) { 19 | if (this.auth.token && !config.headers[RETRY_REQUEST_HEADER]) { 20 | this.setToken(this.auth.token, config); 21 | } 22 | 23 | return config; 24 | } 25 | 26 | async onError (error: AxiosError) { 27 | try { 28 | const { config } = error; 29 | const refreshTokensConfig = this.auth.options.tokenScheme.endpoints?.refresh; 30 | 31 | if (!config || !refreshTokensConfig) { 32 | return Promise.reject(error); 33 | } 34 | 35 | if (config.url === refreshTokensConfig.url) { 36 | return Promise.reject(error); 37 | } 38 | 39 | if (error.response?.status === 401 && !config.headers[RETRY_REQUEST_HEADER]) { 40 | const response = await this.auth.scheme.refreshToken(); 41 | 42 | if (!response) { 43 | return; 44 | } 45 | 46 | config.headers[RETRY_REQUEST_HEADER] = true; 47 | 48 | const token = getProp(response.data, this.auth.scheme.options.token.property); 49 | 50 | if (token) { 51 | this.setToken(token, config); 52 | 53 | return this.send(config); 54 | } 55 | } 56 | } catch (e) { 57 | return Promise.reject(e); 58 | } 59 | 60 | return Promise.reject(error); 61 | } 62 | 63 | async send (endpoint: AxiosRequestConfig): Promise { 64 | if (!this.auth.axios) { 65 | console.error( 66 | `[${this.auth.options.moduleName}] Axios module not found` 67 | ); 68 | 69 | return; 70 | } 71 | 72 | if (this.auth.options.baseURL) { 73 | endpoint.baseURL = this.auth.options.baseURL; 74 | } 75 | 76 | if (this.auth.options.fingerprint.enabled && this.auth.storage.store.fingerprint) { 77 | endpoint.data = Object.assign( 78 | {}, 79 | tryParseJSON(endpoint.data), 80 | { [this.auth.options.fingerprint.property]: this.auth.storage.store.fingerprint } 81 | ); 82 | } 83 | 84 | if (process.server) { 85 | endpoint.headers = { ...endpoint.headers, 'User-Agent': this.auth?.req.headers['user-agent'] }; 86 | } 87 | 88 | return await this.auth.axios.request(endpoint); 89 | } 90 | 91 | private setToken (token: string, endpoint: AxiosRequestConfig) { 92 | if (token) { 93 | endpoint.headers[this.auth.scheme.options.token.headerName] = `${this.auth.scheme.options.token.prefix || 'Bearer'} ${token}`.trim(); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/runtime/schemes/token/index.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | import { Auth } from '../../core/auth'; 3 | import { getProp } from '../../utils'; 4 | import { Scheme } from '../scheme'; 5 | import { SanctumAuthResponse, User } from '../../types'; 6 | import { TokenSchemeOptions } from './types'; 7 | 8 | export class TokenScheme extends Scheme { 9 | constructor ( 10 | protected auth: Auth, 11 | public options: TokenSchemeOptions 12 | ) { 13 | super(); 14 | } 15 | 16 | async login (payload: any) { 17 | const endpoint = this.options.endpoints?.login; 18 | 19 | if (!endpoint) { 20 | return; 21 | } 22 | 23 | this.reset(); 24 | 25 | endpoint.data = { ...payload }; 26 | 27 | const response = await this.tokenRequest(endpoint); 28 | 29 | await this.fetchUser(); 30 | 31 | return response; 32 | } 33 | 34 | async logout () { 35 | const endpoint = this.options.endpoints?.logout; 36 | 37 | if (!endpoint) { 38 | return; 39 | } 40 | 41 | const response = await this.auth.request(endpoint); 42 | 43 | this.reset(); 44 | 45 | return response; 46 | } 47 | 48 | updateToken (response: SanctumAuthResponse) { 49 | const expiredAt = String(this.options.token.expiredAtProperty); 50 | 51 | this.token = getProp(response.data, this.options.token.property); 52 | this.auth.storage.store.setExpiredAt(new Date(getProp(response.data, expiredAt))); 53 | } 54 | 55 | async fetchUser () { 56 | const response = await this.auth.request(this.options.endpoints!.user); 57 | this.auth.user = getProp(response.data, this.options.user?.property) as User; 58 | } 59 | 60 | clearToken (): void { 61 | this.token = null; 62 | } 63 | 64 | reset (): void { 65 | this.clearToken(); 66 | this.auth.storage.store.setUser(null); 67 | } 68 | 69 | async refreshToken () { 70 | const endpoint = this.options.endpoints?.refresh; 71 | 72 | if (!endpoint) { 73 | return; 74 | } 75 | 76 | try { 77 | const response = await this.tokenRequest(endpoint); 78 | 79 | if (!response) { 80 | return response; 81 | } 82 | 83 | if (process.server && !this.auth.res.writableEnded) { 84 | this.auth.res.setHeader('set-cookie', response.headers['set-cookie'] ?? []); 85 | } 86 | 87 | await this.fetchUser(); 88 | 89 | return response; 90 | } catch (e) {} 91 | } 92 | 93 | check () { 94 | return { 95 | tokenExpired: this.expiredAt ? Date.now() >= this.expiredAt.getTime() : false 96 | }; 97 | } 98 | 99 | get token () { 100 | return this.auth.storage.store.token; 101 | } 102 | 103 | set token (value: string | null) { 104 | this.auth.storage.store.setToken(value); 105 | } 106 | 107 | get expiredAt () { 108 | const date = this.auth.storage.store.expired_at; 109 | 110 | if (date instanceof String) { 111 | return new Date(date); 112 | } 113 | 114 | return this.auth.storage.store.expired_at; 115 | } 116 | 117 | private tokenRequest (endpoint: AxiosRequestConfig) { 118 | return new Promise((resolve, reject) => { 119 | this.auth.request(endpoint) 120 | .then((response) => { 121 | this.updateToken(response); 122 | resolve(response); 123 | }) 124 | .catch(error => reject(error)); 125 | }); 126 | } 127 | } 128 | 129 | export * from './types'; 130 | -------------------------------------------------------------------------------- /src/runtime/core/auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @todo Cookie Scheme 3 | */ 4 | 5 | import { Router } from 'vue-router'; 6 | import { AxiosRequestConfig } from 'axios'; 7 | import { NodeIncomingMessage, NodeServerResponse } from 'h3'; 8 | import { NuxtApp } from '#app/nuxt'; 9 | import { defaultOptions, ModuleOptions } from '../../options'; 10 | import { TokenScheme, TokenSchemeOptions } from '../schemes'; 11 | import { User } from '../types'; 12 | import { Storage } from './storage'; 13 | import { RequestHandler } from './request'; 14 | import { Fingerprint } from './fingerprint'; 15 | import { useRouter, useRequestEvent } from '#imports'; 16 | 17 | export class Auth { 18 | public storage: Storage; 19 | public scheme: TokenScheme; 20 | public router: Router; 21 | public req: NodeIncomingMessage; 22 | public res: NodeServerResponse; 23 | public requestHandler: RequestHandler; 24 | protected fingerprint: Fingerprint; 25 | 26 | constructor ( 27 | public nuxt: NuxtApp, 28 | public options: ModuleOptions & { moduleName: string } 29 | ) { 30 | this.req = useRequestEvent()?.req; 31 | this.res = useRequestEvent()?.res; 32 | 33 | this.router = useRouter(); 34 | 35 | this.requestHandler = new RequestHandler(this); 36 | this.scheme = this.makeScheme(options.tokenScheme); 37 | this.fingerprint = new Fingerprint(this); 38 | 39 | this.storage = new Storage(nuxt, options).initStore(); 40 | } 41 | 42 | get user () { 43 | return this.storage.store.user; 44 | } 45 | 46 | set user (payload: User | null) { 47 | this.storage.store.setUser(payload); 48 | } 49 | 50 | get loggedIn (): boolean { 51 | return this.storage.store.loggedIn; 52 | } 53 | 54 | get token () { 55 | return this.storage.store.token; 56 | } 57 | 58 | get axios () { 59 | return this.nuxt.$axios; 60 | } 61 | 62 | async run () { 63 | let fingerprint: string | null; 64 | 65 | if (this.options.fingerprint?.enabled && 66 | !this.storage.store.fingerprint && 67 | (fingerprint = await this.fingerprint.generate()) 68 | ) { 69 | this.storage.store.setFingerprint(fingerprint); 70 | } 71 | 72 | return this.scheme.refreshToken(); 73 | } 74 | 75 | async login (payload: any, redirect = true, nativeRedirect = false) { 76 | const response = await this.scheme.login(payload); 77 | 78 | if (redirect) { 79 | await this.redirectTo('afterLogin', nativeRedirect); 80 | } 81 | 82 | return response; 83 | } 84 | 85 | async logout (redirect = true, nativeRedirect = false) { 86 | const response = await this.scheme.logout(); 87 | 88 | if (redirect) { 89 | this.redirectTo('afterLogout', nativeRedirect); 90 | } 91 | 92 | return response; 93 | } 94 | 95 | request (endpoint: AxiosRequestConfig) { 96 | return this.requestHandler.send(endpoint); 97 | } 98 | 99 | async redirectTo (name: keyof typeof defaultOptions.redirects, native = false) { 100 | if (native && process.client) { 101 | const router = this.router.resolve(this.getRedirectRoute(name)); 102 | 103 | window.location.href = router.href; 104 | return; 105 | } 106 | 107 | return await this.router.push(this.getRedirectRoute(name)); 108 | } 109 | 110 | getRedirectRoute (name: keyof typeof defaultOptions.redirects) { 111 | return this.options.redirects[name]; 112 | } 113 | 114 | private makeScheme (options: TokenSchemeOptions) { 115 | return new TokenScheme(this, options); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Sanctum 2 |

✨Easy authorization via Laravel Sanctum for Nuxt 3!

3 | 4 |

5 | 6 | 7 | 8 |

9 | 10 | So far, only the token authorization scheme is available. 11 | Please note that in this scheme, only the backend itself sets a cookie with the main token and a refresh token. 12 | 13 | ## Installation 14 | 15 | ``` 16 | yarn add @plenexy/nuxt-sanctum @nuxtjs-alt/axios axios @pinia/nuxt pinia 17 | ``` 18 | 19 |
20 | Working example of nuxt.config.ts 21 | 22 | ```typescript 23 | export default defineNuxtConfig({ 24 | modules: [ 25 | "@plenexy/nuxt-sanctum", 26 | "@nuxtjs-alt/axios", 27 | "@pinia/nuxt" 28 | ], 29 | axios: { 30 | credentials: true, 31 | baseURL: process.env.API_BASE_URL, 32 | }, 33 | sanctum: { 34 | baseURL: process.env.AUTH_BASE_URL, // optional 35 | globalMiddleware: false, 36 | redirects: { 37 | afterLogin: "/", 38 | afterLogout: "/auth/login", 39 | toLogin: "/auth/login", 40 | home: "/", 41 | }, 42 | tokenScheme: { 43 | endpoints: { 44 | login: { 45 | url: "/api/auth/users/login", 46 | method: "post", 47 | }, 48 | logout: { 49 | url: "/api/auth/logout", 50 | method: "post", 51 | }, 52 | user: { 53 | url: "/api/auth/me", 54 | method: "post", 55 | }, 56 | refresh: { 57 | url: "/api/auth/refresh-tokens", 58 | method: "post", 59 | }, 60 | }, 61 | user: { 62 | property: false, 63 | }, 64 | token: { 65 | property: "access_token", 66 | headerName: "Authorization", 67 | expiredAtProperty: "expired_at", 68 | }, 69 | refreshToken: { 70 | property: "refresh_token", 71 | }, 72 | }, 73 | }, 74 | }); 75 | ``` 76 |
77 | 78 |
79 | Available configuration 80 | 81 | ```typescript 82 | interface ModuleOptions { 83 | baseURL?: string; 84 | globalMiddleware?: boolean; 85 | pinia?: { 86 | namespace?: string; 87 | }; 88 | redirects: { 89 | home: string; 90 | toLogin: string; 91 | afterLogin: string; 92 | afterLogout: string; 93 | }, 94 | tokenScheme?: { 95 | endpoints: { 96 | login: AxiosRequestConfig; 97 | user: AxiosRequestConfig; 98 | refresh?: AxiosRequestConfig | false; 99 | logout?: AxiosRequestConfig | false; 100 | }, 101 | user: { 102 | property?: string | false; 103 | }, 104 | token: { 105 | prefix?: string | false; 106 | property: string; 107 | headerName?: string; 108 | expiredAtProperty?: string | false; 109 | }, 110 | refreshToken?: { 111 | property: string | false; 112 | } 113 | } 114 | } 115 | ``` 116 |
117 | 118 |
119 | Default configuration values 120 | The values for the parameters from the configuration that you skip will be taken from the default configuration. 121 | 122 | ```typescript 123 | { 124 | globalMiddleware: true, 125 | fingerprint: { 126 | enabled: true, 127 | property: 'fingerprint' 128 | }, 129 | pinia: { 130 | namespace: 'auth' 131 | }, 132 | redirects: { 133 | home: '/', 134 | toLogin: '/auth/login', 135 | afterLogin: '/', 136 | afterLogout: '/' 137 | } 138 | } 139 | ``` 140 |
141 | 142 | ## Example 143 | 144 | ```html 145 | 159 | 160 | 170 | ``` 171 | 172 | ## Middleware 173 | 174 | Example of a page accessible only to an unauthorized user if you have global middleware enabled: 175 | 176 | ```html 177 | 182 | ``` 183 | 184 | Example of a page accessible only to an authorized user if you have global middleware disabled: 185 | 186 | ```html 187 | 192 | ``` 193 | 194 | ## Fingerprint 195 | 196 | Nuxt-sanctum generates a unique client ID based on its IP address and user-agent. It can be used to implement multiple authorization sessions and cancel them at the user's choice (for example, to view active sessions). 197 | You can disable the generation and sending of the parameter to your endpoints or change the name of the parameter using the appropriate configuration (*fingerprint.enabled* and *fingerprint.property*). 198 | 199 | ## Module Dependencies 200 | * @nuxtjs-alt/axios 201 | * @pinia/nuxt 202 | 203 | ## Development 204 | 205 | - Run `yarn dev:prepare` to generate type stubs. 206 | - Use `yarn dev` to start [playground](./playground) in development mode. 207 | 208 | ## License 209 | 210 | [MIT License](./LICENSE) - Copyright (c) SKYROSES & Plenexy --------------------------------------------------------------------------------