Skip to content
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

feat: globalMiddleware option #163

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineNuxtConfig({
// },
// registerComponents: true,
// augmentContext: true,
// globalMiddleware: false,
},
})
```
Expand All @@ -63,17 +64,27 @@ Check out the [Hanko documentation](https://docs.hanko.io/guides/vue) to learn m

### Middleware

By default two new route middleware are available in your Nuxt app: `hanko-logged-in` and `hanko-logged-out`.
By default three new route middlewares are available in your Nuxt app: `hanko-logged-in`, `hanko-logged-out` and `hanko-allow-all`.

- `hanko-logged-in` will prevent access to a page unless you are logged in. (It will redirect you to `redirects.login` instead, and then redirect back to this page once you login. You can disable this behaviour by setting `redirects.followRedirect` to `false`.)
- `hanko-logged-out` will prevent access to a page unless you are logged out. (It will redirect you to `redirects.success` when you log in, and otherwise to `redirects.home`.)
- `hanko-allow-all` will allow all users access to page, even if the `globalMiddleware` option is set to `true`. (If the `globalMiddleware` option is not set to `true`, this middleware has no effect.)

You can also create your own middleware for full control.

### Global Middleware

If the `globalMiddleware` configuration is set to `true`, the middleware is automatically applied to all of your pages.
You can still override this behavior on each page, by applying the `hanko-logged-out` or the `hanko-allow-all` middleware.

**Note**: The `globalMiddleware` option will not apply any authentication checks to your API-paths.

### Auto-imports

`useHanko` is exposed in the Vue part of your app to allow you direct access to the Hanko API. You can access the current user and much more. **Note**: It will return `null` on the server.

The `hankoLoggedIn` and `hankoLoggedOut` middleware are exposed to enable you to extend their functionality, such as creating a custom global middleware.

### Server utilities

By default you can access a verified JWT context on `event.context.hanko`. (It will be undefined if the user is not logged in.) If you want to handle this yourself you can set `augmentContext: false`.
Expand Down
6 changes: 6 additions & 0 deletions playground/pages/about.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
<script setup lang="ts">
definePageMeta({
middleware: ['hanko-allow-all'],
})
</script>

<template>
<main>
<h1>About</h1>
Expand Down
20 changes: 19 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ModuleOptions {
apiURL?: string
registerComponents?: boolean
augmentContext?: boolean
globalMiddleware?: boolean
cookieName?: string
redirects?: {
login?: string
Expand All @@ -34,6 +35,7 @@ export default defineNuxtModule<ModuleOptions>({
apiURL: '',
registerComponents: true,
augmentContext: true,
globalMiddleware: false,
cookieName: 'hanko',
redirects: {
login: '/login',
Expand Down Expand Up @@ -86,7 +88,15 @@ export default defineNuxtModule<ModuleOptions>({
})
}

for (const name of ['logged-in', 'logged-out']) {
if (options.globalMiddleware) {
addRouteMiddleware({
name: 'hanko-global-logged-in',
path: resolver.resolve('./runtime/middleware/global-logged-in'),
global: true,
})
}

for (const name of ['allow-all', 'logged-in', 'logged-out']) {
addRouteMiddleware({
name: `hanko-${name}`,
path: resolver.resolve(`./runtime/middleware/${name}`),
Expand All @@ -98,6 +108,14 @@ export default defineNuxtModule<ModuleOptions>({
from: resolver.resolve('./runtime/composables/index'),
imports: ['useHanko'],
})
addImportsSources({
from: resolver.resolve('./runtime/middleware/logged-in.ts'),
imports: ['hankoLoggedIn'],
})
addImportsSources({
from: resolver.resolve('./runtime/middleware/logged-out.ts'),
imports: ['hankoLoggedOut'],
})

const hankoElementsTemplate = addTemplate({
filename: 'hanko-elements.mjs',
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/middleware/allow-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineNuxtRouteMiddleware } from '#imports'

export default defineNuxtRouteMiddleware(() => true)
41 changes: 41 additions & 0 deletions src/runtime/middleware/global-logged-in.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { defineNuxtRouteMiddleware, hankoLoggedIn } from '#imports'

export default defineNuxtRouteMiddleware(async (to, from) => {
// If the requested location doesn't exist: let the router handle it
if (to.matched.length === 0) return true

// Don't trigger on client-side navigation on the same-page
// (changes in to.query or to.hash)
if (!import.meta.server && to.path === from.path) return true

// If a hanko middleware is explicitly set, that middleware handles
// navigation and the default hankoLoggedIn is skipped
if (Array.isArray(to.meta.middleware)) {
if (to.meta.middleware.some(isHankoMiddleware)) {
return true
}
}
else if (isHankoMiddleware(to.meta.middleware)) {
return true
}

// If no hanko middleware is set, default to hankoLoggedIn
return await hankoLoggedIn(to, from)
})

/**
* Checks if the given middleware is a valid Hanko middleware.
*
* @param middleware - The middleware to check.
* @description If middleware is undefined or a NavigationGuard (function), it is not a Hanko middleware.
* A valid Hanko middleware is a MiddlewareKey (string) with one of the following values:
* - `hanko-logged-in`
* - `hanko-logged-out`
* - `hanko-allow-all`
*/
const isHankoMiddleware = (middleware: unknown) => {
return (
typeof middleware === 'string'
&& ['hanko-allow-all', 'hanko-logged-in', 'hanko-logged-out'].includes(middleware)
)
}
7 changes: 5 additions & 2 deletions src/runtime/middleware/logged-in.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { withQuery } from 'ufo'
import type { RouteMiddleware } from '#app'
import {
defineNuxtRouteMiddleware,
navigateTo,
Expand All @@ -8,7 +9,7 @@ import {
useRequestEvent,
} from '#imports'

export default defineNuxtRouteMiddleware(async (to) => {
export const hankoLoggedIn: RouteMiddleware = async (to) => {
const redirects = useAppConfig().hanko.redirects

if (import.meta.server) {
Expand All @@ -33,4 +34,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
removeHankoHook()
removeRouterHook()
})
})
}

export default defineNuxtRouteMiddleware(hankoLoggedIn)
7 changes: 5 additions & 2 deletions src/runtime/middleware/logged-out.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RouteMiddleware } from '#app'
import {
defineNuxtRouteMiddleware,
navigateTo,
Expand All @@ -7,7 +8,7 @@ import {
useRequestEvent,
} from '#imports'

export default defineNuxtRouteMiddleware(async (to) => {
export const hankoLoggedOut: RouteMiddleware = async (to) => {
const redirects = useAppConfig().hanko.redirects

if (import.meta.server) {
Expand Down Expand Up @@ -36,4 +37,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
removeHankoHook()
removeRouterHook()
})
})
}

export default defineNuxtRouteMiddleware(hankoLoggedOut)
63 changes: 63 additions & 0 deletions test/globalMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import { setup, fetch } from '@nuxt/test-utils'

await setup({
rootDir: fileURLToPath(new URL('../playground', import.meta.url)),
nuxtConfig: { hanko: { globalMiddleware: true } },
})

const fetchOptions: RequestInit = { redirect: 'manual' }

describe('logged-out user', async () => {
it('allows pages with `hanko-logged-out` middleware', async () => {
const res = await fetch('/login', fetchOptions)
expect(res.status).toBe(200)
})

it('`hanko-logged-in` middleware redirects to the login page', async () => {
const res = await fetch('/protected', fetchOptions)
expect(res.headers.get('location')).toBe('/login?redirect=/protected')
})

it('global middleware redirects on page without explicit middleware', async () => {
const res = await fetch('/', fetchOptions)
expect(res.headers.get('location')).toBe('/login?redirect=/')
})

it('allows `hanko-allow-all` with global middleware', async () => {
const res = await fetch('/about', fetchOptions)
expect(res.status).toBe(200)
})
})

describe('logged-in user', async () => {
const authenticatedFetchOptions: RequestInit = {
...fetchOptions,
headers: {
cookie:
// This will not work. It was an actually valid cookie, signed for localhost:3000, but it has a lifetime of 1 hour.
'hanko=eyJhbGciOiJSUzI1NiIsImtpZCI6IjVjYzU1MDFmLWYxNWQtNDY0Ni1iMmRiLTlkNzcwODgwYWM0NCIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibG9jYWxob3N0Il0sImVtYWlsIjp7ImFkZHJlc3MiOiJtYWlsQGZlbGl4ZG9sZGVyZXIuY29tIiwiaXNfcHJpbWFyeSI6dHJ1ZSwiaXNfdmVyaWZpZWQiOnRydWV9LCJleHAiOjE3MjM4NTQ2NDQsImlhdCI6MTcyMzgxMTQ0NCwic3ViIjoiN2JiNzE1MDktMWU4YS00MGY5LTlmY2EtM2ViZjg1NjdjNGJkIn0.wSzfcdCcqs-oQB2Ifq-bKqfJTxwIS9q1xw_2E6lVeRd3dyIcJPtaSHAY-mS798GjKuC9DpkaHQyWVV28xcriY7Csvu09vrFfcqcX1cj_y9eKQPp9J_q1Dt8-Q5hOuXasMxQZg9s9R0OgLXVXqQ_G8M5BBu68ahANR9pK1cxyJ0QN5u04FpK5xmgNsdgKvEqaQqgbdvU5h-7F-GBl07of8c4VTd2NmuNl9jINdeTSYpkbEFEDljjG4ll2tw3CSfCSR_aonz3WR2R2bjbMuIVPW6HnDKcAdJ2eXdF7L3kfqg-NdA-IrPef9GlmlFDNbrTdrH8WWhZ17x-euRsJd08VAD0TiJWPoPyV_S0T9W-J6Dsle0zn6frbXkzcQnDQVEZv7w6nCWZaAhfqEbJUni9aE8BDskOZRC2aRDyntfGTymfUo0KheJ5NgzaWEME4VAT0zhWymJwOaY1LqdSeYowu1iet_yYnq6fdWPKRehxvZGIFsfKRBcO17INGwDQFDn0n6c1zsIqUYTNh6vp0H4q3mdwQPsnC2gtVA8phYiRr0uznumfLIjEoB53xvU2l0r_IVZukDY89kqxkdgwe1o5kl5Z43cgAG1Ohv1EVeM0z7aYYMqrrDfC7lTxTSVG07IjtP75NMZVKsmUzhKLtM1PaIuEYUi4RPlRxBsizJag_MIs',
},
}

it('allows pages with `hanko-logged-in` middleware', async () => {
const res = await fetch('/protected', authenticatedFetchOptions)
expect(res.status).toBe(200)

Check failure on line 46 in test/globalMiddleware.test.ts

View workflow job for this annotation

GitHub Actions / test

test/globalMiddleware.test.ts > logged-in user > allows pages with `hanko-logged-in` middleware

AssertionError: expected 302 to be 200 // Object.is equality - Expected + Received - 200 + 302 ❯ test/globalMiddleware.test.ts:46:24
})

it('`hanko-logged-out` redirects logged-in user to the home page', async () => {
const res = await fetch('/login', authenticatedFetchOptions)
expect(res.headers.get('location')).toBe('/')

Check failure on line 51 in test/globalMiddleware.test.ts

View workflow job for this annotation

GitHub Actions / test

test/globalMiddleware.test.ts > logged-in user > `hanko-logged-out` redirects logged-in user to the home page

AssertionError: expected null to be '/' // Object.is equality - Expected: "/" + Received: null ❯ test/globalMiddleware.test.ts:51:41
})

it('allows page without explicit middleware', async () => {
const res = await fetch('/', authenticatedFetchOptions)
expect(res.status).toBe(200)

Check failure on line 56 in test/globalMiddleware.test.ts

View workflow job for this annotation

GitHub Actions / test

test/globalMiddleware.test.ts > logged-in user > allows page without explicit middleware

AssertionError: expected 302 to be 200 // Object.is equality - Expected + Received - 200 + 302 ❯ test/globalMiddleware.test.ts:56:24
})

it('allows `hanko-allow-all` with global middleware', async () => {
const res = await fetch('/about', authenticatedFetchOptions)
expect(res.status).toBe(200)
})
})
Loading