Skip to content

Add sanctum token auth support #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Dec 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
addImportsDir,
} from '@nuxt/kit'
import { defu } from 'defu'
import type { ModuleOptions } from './runtime/types'
import type { ModuleOptions } from './runtime/types/options'
import { registerTypeTemplates } from './templates'

const MODULE_NAME = 'nuxt-laravel-echo'

Expand All @@ -19,6 +20,7 @@ const defaultModuleOptions: ModuleOptions = {
scheme: 'https',
transports: ['ws', 'wss'],
authentication: {
mode: 'cookie',
baseUrl: 'http://localhost:80',
authEndpoint: '/broadcasting/auth',
csrfEndpoint: '/sanctum/csrf-cookie',
Expand Down Expand Up @@ -50,6 +52,8 @@ export default defineNuxtModule<ModuleOptions>({
addPlugin(resolver.resolve('./runtime/plugin.client'))
addImportsDir(resolver.resolve('./runtime/composables'))

registerTypeTemplates(resolver)

logger.info('Laravel Echo module initialized!')
},
})
2 changes: 1 addition & 1 deletion src/runtime/composables/useEcho.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type Echo from 'laravel-echo'
import type { SupportedBroadcaster } from '../types'
import type { SupportedBroadcaster } from '../types/options'
import { useNuxtApp } from '#app'

export const useEcho = (): Echo<SupportedBroadcaster> => {
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/composables/useEchoAppConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { EchoAppConfig } from '../types/config'
import { useAppConfig } from '#app'

export const useEchoAppConfig = (): EchoAppConfig => {
return (useAppConfig().echo ?? {}) as EchoAppConfig
}
2 changes: 1 addition & 1 deletion src/runtime/composables/useEchoConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModuleOptions } from '../types'
import type { ModuleOptions } from '../types/options'
import { useRuntimeConfig } from '#imports'

export const useEchoConfig = (): ModuleOptions => {
Expand Down
83 changes: 64 additions & 19 deletions src/runtime/plugin.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type { ChannelAuthorizationData } from 'pusher-js/types/src/core/auth/opt
import { type ConsolaInstance, createConsola } from 'consola'
import type { FetchOptions } from 'ofetch'
import { useEchoConfig } from './composables/useEchoConfig'
import type { Authentication, ModuleOptions, SupportedBroadcaster } from './types'
import { createError, defineNuxtPlugin, useCookie } from '#app'
import type { Authentication, ModuleOptions, SupportedBroadcaster } from './types/options'
import { useEchoAppConfig } from './composables/useEchoAppConfig'
import { createError, defineNuxtPlugin, useCookie, updateAppConfig, type NuxtApp } from '#app'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const Pusher = (PusherPkg as any).default || PusherPkg
Expand All @@ -26,6 +27,7 @@ function createEchoLogger(logLevel: number) {
const readCsrfCookie = (name: string) => useCookie(name, { readonly: true })

function createFetchClient(
app: NuxtApp,
authentication: Required<Authentication>,
logger: ConsolaInstance
) {
Expand All @@ -35,35 +37,55 @@ function createFetchClient(
retry: false,

async onRequest(context) {
let csrfToken = readCsrfCookie(authentication.csrfCookie)
if (authentication.mode === 'cookie') {
let csrfToken = readCsrfCookie(authentication.csrfCookie)

if (!csrfToken.value) {
await $fetch(authentication.csrfEndpoint, {
baseURL: authentication.baseUrl,
credentials: 'include',
retry: false,
})
if (!csrfToken.value) {
await $fetch(authentication.csrfEndpoint, {
baseURL: authentication.baseUrl,
credentials: 'include',
retry: false,
})

csrfToken = readCsrfCookie(authentication.csrfCookie)
}
csrfToken = readCsrfCookie(authentication.csrfCookie)
}

if (!csrfToken.value) {
logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`)
return
if (!csrfToken.value) {
logger.warn(`${authentication.csrfCookie} cookie is missing, unable to set ${authentication.csrfHeader} header`)
return
}

context.options.headers.set(authentication.csrfHeader, csrfToken.value)
}

context.options.headers.set(authentication.csrfHeader, csrfToken.value)
if (authentication.mode === 'token') {
const { tokenStorage } = useEchoAppConfig()

if (!tokenStorage) {
throw createError('Token storage is not defined')
}

const token = await tokenStorage.get(app)

if (!token) {
logger.debug('Authorization token is missing, unable to set header')
return
}

context.options.headers.set('Authorization', `Bearer ${token}`)
}
},
}

return $fetch.create(fetchOptions)
}

function createAuthorizer(
app: NuxtApp,
authentication: Required<Authentication>,
logger: ConsolaInstance
) {
const client = createFetchClient(authentication, logger)
const client = createFetchClient(app, authentication, logger)

return (channel: Channel, _: Options) => {
return {
Expand All @@ -86,12 +108,13 @@ function createAuthorizer(
}
}

function prepareEchoOptions(config: ModuleOptions, logger: ConsolaInstance) {
function prepareEchoOptions(app: NuxtApp, config: ModuleOptions, logger: ConsolaInstance) {
const forceTLS = config.scheme === 'https'
const additionalOptions = config.properties || {}

const authorizer = config.authentication
? createAuthorizer(
app,
config.authentication as Required<Authentication>,
logger
)
Expand Down Expand Up @@ -127,12 +150,34 @@ function prepareEchoOptions(config: ModuleOptions, logger: ConsolaInstance) {
}
}

export default defineNuxtPlugin((_nuxtApp) => {
async function setupDefaultTokenStorage(nuxtApp: NuxtApp, logger: ConsolaInstance) {
logger.debug(
'Token storage is not defined, switch to default cookie storage',
)

const defaultStorage = await import('./storages/cookieTokenStorage')

nuxtApp.runWithContext(() => {
updateAppConfig({
echo: {
tokenStorage: defaultStorage.cookieTokenStorage,
},
})
})
}

export default defineNuxtPlugin(async (_nuxtApp) => {
const nuxtApp = _nuxtApp as NuxtApp
const config = useEchoConfig()
const appConfig = useEchoAppConfig()
const logger = createEchoLogger(config.logLevel)

if (config.authentication?.mode === 'token' && !appConfig.tokenStorage) {
await setupDefaultTokenStorage(nuxtApp, logger)
}

window.Pusher = Pusher
window.Echo = new Echo(prepareEchoOptions(config, logger))
window.Echo = new Echo(prepareEchoOptions(nuxtApp, config, logger))

logger.debug('Laravel Echo client initialized')

Expand Down
25 changes: 25 additions & 0 deletions src/runtime/storages/cookieTokenStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { unref } from 'vue'
import type { TokenStorage } from '../types/config'
import { useCookie, type NuxtApp } from '#app'

const cookieTokenKey = 'sanctum.token.cookie'

/**
* Token storage using a secure cookie.
* Works with both CSR/SSR modes.
*/
export const cookieTokenStorage: TokenStorage = {
async get(app: NuxtApp) {
return app.runWithContext(() => {
const cookie = useCookie(cookieTokenKey, { readonly: true })
return unref(cookie.value) ?? undefined
})
},

async set(app: NuxtApp, token?: string) {
await app.runWithContext(() => {
const cookie = useCookie(cookieTokenKey, { secure: true })
cookie.value = token
})
},
}
25 changes: 25 additions & 0 deletions src/runtime/types/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { NuxtApp } from '#app'

/**
* Handlers to work with authentication token.
*/
export interface TokenStorage {
/**
* Function to load a token from the storage.
*/
get: (app: NuxtApp) => Promise<string | undefined>
/**
* Function to save a token to the storage.
*/
set: (app: NuxtApp, token?: string) => Promise<void>
}

/**
* Echo configuration for the application side with user-defined handlers.
*/
export interface EchoAppConfig {
/**
* Token storage handlers to be used by the client.
*/
tokenStorage?: TokenStorage
}
5 changes: 5 additions & 0 deletions src/runtime/types.ts → src/runtime/types/options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
export type SupportedBroadcaster = 'reverb' | 'pusher'

export interface Authentication {
/**
* Authentication mode 'cookie' or 'token'
* @default 'cookie'
*/
mode: 'cookie' | 'token'
/**
* The base URL of Laravel application.
* @default 'http://localhost:80'
Expand Down
29 changes: 29 additions & 0 deletions src/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { addTypeTemplate, type Resolver } from '@nuxt/kit'

export const registerTypeTemplates = (resolver: Resolver) => {
addTypeTemplate({
filename: 'types/echo.d.ts',
getContents: () => `// Generated by nuxt-laravel-echo module
import type { EchoAppConfig } from '${resolver.resolve('./runtime/types/config.ts')}';

declare module 'nuxt/schema' {
interface AppConfig {
echo?: EchoAppConfig;
}
interface AppConfigInput {
echo?: EchoAppConfig;
}
}

declare module '@nuxt/schema' {
interface AppConfig {
echo?: EchoAppConfig;
}
interface AppConfigInput {
echo?: EchoAppConfig;
}
}

export {};`,
})
}
Loading