@norriq/nuxt-auth 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 NORRIQ. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the
4
+ proprietary property of NORRIQ. Unauthorized use, copying, modification,
5
+ distribution, or any other exploitation of the Software, in whole or in part,
6
+ is strictly prohibited without the prior written consent of NORRIQ.
7
+
8
+ For licensing inquiries, contact: info@norriq.com
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @norriq/nuxt-auth
2
+
3
+ Nuxt layer providing CIAM authentication via `@sidebase/nuxt-auth` with NORRIQ.Auth integration.
4
+
5
+ Adds server routes for OAuth2 flows, token refresh, credential login, and session management.
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ // nuxt.config.ts
11
+ export default defineNuxtConfig({
12
+ extends: ['@norriq/nuxt-auth'],
13
+ })
14
+ ```
15
+
16
+ ## Environment variables
17
+
18
+ | Variable | Scope | Required | Description |
19
+ |----------|-------|----------|-------------|
20
+ | `NUXT_AUTH_SECRET` | Server | Yes | Session encryption secret |
21
+ | `NUXT_AUTH_URL` | Server | Yes | NORRIQ.Auth server URL (internal) |
22
+ | `NUXT_AUTH_URL_INTERNAL` | Server | No | Overrides `authUrl` for server-to-server calls |
23
+ | `NUXT_AUTH_CLIENT_SECRET` | Server | No | OAuth2 client secret |
24
+ | `NUXT_PUBLIC_AUTH_URL` | Public | Yes | NORRIQ.Auth server URL (browser) |
25
+ | `NUXT_PUBLIC_AUTH_CLIENT_ID` | Public | Yes | OAuth2 client ID |
26
+ | `NUXT_PUBLIC_AUTH_AREA_ID` | Public | Yes | NORRIQ.Auth area ID |
27
+
28
+ ## Exports
29
+
30
+ | Path | Description |
31
+ |------|-------------|
32
+ | `@norriq/nuxt-auth` | Nuxt layer entry (nuxt.config.ts) |
33
+ | `@norriq/nuxt-auth/authorizeRedirect` | Utility to build OAuth2 authorize redirect URLs |
34
+ | `@norriq/nuxt-auth/forceReauth` | Cookie-based force-reauthentication utilities |
35
+
36
+ ## Server routes
37
+
38
+ | Route | Method | Description |
39
+ |-------|--------|-------------|
40
+ | `/api/auth/authorize` | GET | Initiates OAuth2 flow with PKCE |
41
+ | `/api/auth/credentials-login` | POST | Direct username/password login |
42
+ | `/api/auth/refresh-token` | POST | Refresh access token |
43
+ | `/api/auth/oauth-callback/[connectorKey]` | GET | OAuth2 callback handler |
44
+ | `/api/auth/[...]` | * | NextAuth.js catch-all handler |
package/nuxt.config.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { dirname, resolve } from 'node:path'
3
+
4
+ declare module 'nuxt/schema' {
5
+ interface RuntimeConfig {
6
+ authSecret: string
7
+ authClientSecret: string
8
+ authUrl: string
9
+ authUrlInternal: string
10
+ }
11
+ interface PublicRuntimeConfig {
12
+ authUrl: string
13
+ authClientId: string
14
+ authAreaId: string
15
+ }
16
+ }
17
+
18
+ const currentDir = dirname(fileURLToPath(import.meta.url))
19
+
20
+ export default defineNuxtConfig({
21
+ modules: ['@sidebase/nuxt-auth'],
22
+
23
+ auth: {
24
+ provider: { type: 'authjs' },
25
+ globalAppMiddleware: { isEnabled: true },
26
+ sessionRefresh: {
27
+ handler: resolve(currentDir, './utils/auth/AuthRefreshHandler'),
28
+ enableOnWindowFocus: true,
29
+ enablePeriodically: 60 * 2 * 1000,
30
+ },
31
+ baseURL: '/api/auth',
32
+ },
33
+
34
+ experimental: {
35
+ asyncContext: true,
36
+ },
37
+
38
+ runtimeConfig: {
39
+ authSecret: '',
40
+ authClientSecret: '',
41
+ authUrl: '',
42
+ authUrlInternal: '',
43
+ public: {
44
+ authUrl: undefined,
45
+ authClientId: undefined,
46
+ authAreaId: undefined,
47
+ },
48
+ },
49
+ })
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@norriq/nuxt-auth",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "exports": {
7
+ ".": "./nuxt.config.ts",
8
+ "./authorizeRedirect": "./utils/auth/authorizeRedirect.ts",
9
+ "./forceReauth": "./utils/auth/forceReauth.ts"
10
+ },
11
+ "files": [
12
+ "nuxt.config.ts",
13
+ "server",
14
+ "utils",
15
+ "types"
16
+ ],
17
+ "license": "SEE LICENSE IN LICENSE",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@sidebase/nuxt-auth": "^1.1.1",
23
+ "jose": "^6.1.3",
24
+ "next-auth": "~4.24.14"
25
+ },
26
+ "peerDependencies": {
27
+ "nuxt": ">=3.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "h3": "^1.15.0",
31
+ "typescript-eslint": "^8.0.0",
32
+ "vitest": "^3.2.4"
33
+ },
34
+ "scripts": {
35
+ "lint": "eslint .",
36
+ "lint:fix": "eslint --fix .",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
39
+ }
40
+ }
@@ -0,0 +1,34 @@
1
+ import { NuxtAuthHandler } from '#auth'
2
+ import type { JWT } from 'next-auth/jwt'
3
+ import type { Session } from 'next-auth'
4
+
5
+ const runtimeConfig = useRuntimeConfig()
6
+
7
+ export default NuxtAuthHandler({
8
+ secret: runtimeConfig.authSecret,
9
+ providers: [],
10
+
11
+ callbacks: {
12
+ jwt: ({ token }): JWT => token,
13
+ session: ({ session, token }): Session => {
14
+ session.user = {
15
+ name: token.name || '',
16
+ username: token.username || '',
17
+ customerNumber: token.customerNumber || '',
18
+ roles: token.roles || [],
19
+ authInfo: {
20
+ accessToken: token.accessToken,
21
+ refreshToken: token.refreshToken,
22
+ tokenPolicy: token.tokenPolicy,
23
+ authMethod: token.authMethod,
24
+ },
25
+ }
26
+ session.roles = token.roles || []
27
+ return session
28
+ },
29
+ },
30
+
31
+ pages: {
32
+ signIn: '/login',
33
+ },
34
+ })
@@ -0,0 +1,87 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ const allowedConnectors = ['customer', 'norriq'] as const
4
+ type ConnectorKey = (typeof allowedConnectors)[number]
5
+
6
+ function isValidConnector(value: string): value is ConnectorKey {
7
+ return (allowedConnectors as readonly string[]).includes(value)
8
+ }
9
+
10
+ export default defineEventHandler((event) => {
11
+ const query = getQuery(event)
12
+ const connectorKey = typeof query.connector_key === 'string' ? query.connector_key : ''
13
+ const returnUrl = typeof query.return_url === 'string' ? query.return_url : '/'
14
+ // Check both query param and cookie — the cookie is set by setForceReauth() on
15
+ // sign-out, but Nuxt's useCookie() on the client may not relay it as a query param
16
+ // during SPA navigation. The browser always sends the cookie in the request though.
17
+ const forceReauthCookie = getCookie(event, 'auth:forceReauth')
18
+ const forceReauthentication = query.force_reauthentication === 'true' || forceReauthCookie === 'true'
19
+ if (forceReauthCookie) {
20
+ deleteCookie(event, 'auth:forceReauth', { path: '/' })
21
+ }
22
+
23
+ if (!isValidConnector(connectorKey)) {
24
+ throw createError({ statusCode: 400, statusMessage: 'Invalid connector key' })
25
+ }
26
+
27
+ if (!isSafeReturnUrl(returnUrl)) {
28
+ throw createError({ statusCode: 400, statusMessage: 'Invalid return URL' })
29
+ }
30
+
31
+ const state = crypto.randomBytes(32).toString('base64url')
32
+ const codeVerifier = crypto.randomBytes(32).toString('base64url')
33
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')
34
+ const secure = isSecureRequest(event)
35
+
36
+ setCookie(event, 'auth:state', state, {
37
+ httpOnly: true,
38
+ secure,
39
+ sameSite: 'lax',
40
+ path: '/',
41
+ maxAge: 60 * 10,
42
+ })
43
+
44
+ setCookie(event, 'auth:codeVerifier', codeVerifier, {
45
+ httpOnly: true,
46
+ secure,
47
+ sameSite: 'lax',
48
+ path: '/',
49
+ maxAge: 60 * 10,
50
+ })
51
+
52
+ setCookie(event, 'auth:returnUrl', returnUrl, {
53
+ httpOnly: true,
54
+ secure,
55
+ sameSite: 'lax',
56
+ path: '/',
57
+ maxAge: 60 * 10,
58
+ })
59
+
60
+ const config = useRuntimeConfig()
61
+ const authUrl = config.public.authUrl
62
+ const clientId = config.public.authClientId
63
+ const areaId = config.public.authAreaId
64
+
65
+ const requestUrl = getRequestURL(event)
66
+ const host = getRequestHeader(event, 'host') || requestUrl.host
67
+ const forwardedProto = getRequestHeader(event, 'x-forwarded-proto')
68
+ const protocol = forwardedProto ? `${forwardedProto}:` : requestUrl.protocol
69
+
70
+ const params = new URLSearchParams({
71
+ client_id: clientId,
72
+ response_type: 'code',
73
+ redirect_uri: `${protocol}//${host}/api/auth/oauth-callback/${connectorKey}`,
74
+ scope: 'openid offline_access',
75
+ area_id: areaId,
76
+ connector_key: connectorKey,
77
+ state,
78
+ code_challenge: codeChallenge,
79
+ code_challenge_method: 'S256',
80
+ })
81
+
82
+ if (forceReauthentication) {
83
+ params.set('prompt', 'login')
84
+ }
85
+
86
+ return sendRedirect(event, `${authUrl}/connect/authorize?${params}`)
87
+ })
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { EventHandler } from 'h3'
3
+ import { fakeEvent, stubCreateError, catchHttpError, lastRedirectUrl } from '../../test-helpers'
4
+
5
+ const mockGetQuery = vi.fn()
6
+ const mockGetCookie = vi.fn()
7
+ const mockDeleteCookie = vi.fn()
8
+ const mockSetCookie = vi.fn()
9
+ const mockSendRedirect = vi.fn()
10
+ const mockGetRequestHeader = vi.fn()
11
+
12
+ vi.stubGlobal('defineEventHandler', <T>(fn: T): T => fn)
13
+ stubCreateError()
14
+ vi.stubGlobal('getQuery', (event: unknown) => mockGetQuery(event))
15
+ vi.stubGlobal('getCookie', (_event: unknown, name: string) => mockGetCookie(name))
16
+ vi.stubGlobal('deleteCookie', (_event: unknown, name: string, opts?: unknown) => mockDeleteCookie(name, opts))
17
+ vi.stubGlobal('setCookie', (_event: unknown, name: string, value: string, opts?: unknown) =>
18
+ mockSetCookie(name, value, opts)
19
+ )
20
+ vi.stubGlobal('sendRedirect', (_event: unknown, url: string) => mockSendRedirect(url))
21
+ vi.stubGlobal('getRequestURL', () => new URL('https://app.example.com/api/auth/authorize'))
22
+ vi.stubGlobal('getRequestHeader', (_event: unknown, header: string) => mockGetRequestHeader(header))
23
+ vi.stubGlobal('useRuntimeConfig', () => ({
24
+ public: {
25
+ authUrl: 'https://auth.example.com',
26
+ authClientId: 'test-client-id',
27
+ authAreaId: 'test-area',
28
+ },
29
+ }))
30
+ vi.stubGlobal('isSafeReturnUrl', (url: string) => url.startsWith('/') && !url.startsWith('//') && !url.includes('\\'))
31
+ vi.stubGlobal('isSecureRequest', () => true)
32
+
33
+ describe('authorize.get', () => {
34
+ let handler: EventHandler
35
+
36
+ beforeEach(async () => {
37
+ vi.clearAllMocks()
38
+ mockGetRequestHeader.mockImplementation((header: string) => {
39
+ if (header === 'host') return 'app.example.com'
40
+ if (header === 'x-forwarded-proto') return 'https'
41
+ return undefined
42
+ })
43
+
44
+ vi.resetModules()
45
+ const mod = await import('./authorize.get')
46
+ handler = mod.default
47
+ })
48
+
49
+ it('rejects invalid connector key', async () => {
50
+ mockGetQuery.mockReturnValue({ connector_key: 'evil', return_url: '/' })
51
+
52
+ const err = await catchHttpError(() => handler(fakeEvent()))
53
+ expect(err.statusCode).toBe(400)
54
+ expect(err.message).toBe('Invalid connector key')
55
+ })
56
+
57
+ it('rejects unsafe return URL', async () => {
58
+ mockGetQuery.mockReturnValue({ connector_key: 'customer', return_url: '//evil.com' })
59
+
60
+ const err = await catchHttpError(() => handler(fakeEvent()))
61
+ expect(err.statusCode).toBe(400)
62
+ expect(err.message).toBe('Invalid return URL')
63
+ })
64
+
65
+ it('sets state, codeVerifier, and returnUrl cookies', async () => {
66
+ mockGetQuery.mockReturnValue({ connector_key: 'customer', return_url: '/dashboard' })
67
+
68
+ await handler(fakeEvent())
69
+
70
+ const cookieNames = mockSetCookie.mock.calls.map((c: unknown[]) => c[0])
71
+ expect(cookieNames).toContain('auth:state')
72
+ expect(cookieNames).toContain('auth:codeVerifier')
73
+ expect(cookieNames).toContain('auth:returnUrl')
74
+
75
+ const returnUrlCall = mockSetCookie.mock.calls.find((c: unknown[]) => c[0] === 'auth:returnUrl')
76
+ expect(returnUrlCall[1]).toBe('/dashboard')
77
+ })
78
+
79
+ it('redirects to auth server with PKCE params', async () => {
80
+ mockGetQuery.mockReturnValue({ connector_key: 'customer', return_url: '/' })
81
+
82
+ await handler(fakeEvent())
83
+
84
+ expect(mockSendRedirect).toHaveBeenCalledTimes(1)
85
+ const url = lastRedirectUrl(mockSendRedirect)
86
+ expect(url).toContain('https://auth.example.com/connect/authorize')
87
+ expect(url).toContain('client_id=test-client-id')
88
+ expect(url).toContain('response_type=code')
89
+ expect(url).toContain('code_challenge_method=S256')
90
+ expect(url).toContain('code_challenge=')
91
+ expect(url).toContain('state=')
92
+ expect(url).toContain('connector_key=customer')
93
+ })
94
+
95
+ it('adds prompt=login when force reauthentication is set', async () => {
96
+ mockGetQuery.mockReturnValue({
97
+ connector_key: 'norriq',
98
+ return_url: '/',
99
+ force_reauthentication: 'true',
100
+ })
101
+
102
+ await handler(fakeEvent())
103
+
104
+ expect(lastRedirectUrl(mockSendRedirect)).toContain('prompt=login')
105
+ })
106
+
107
+ it('reads force reauth from cookie and deletes it', async () => {
108
+ mockGetQuery.mockReturnValue({ connector_key: 'customer', return_url: '/' })
109
+ mockGetCookie.mockImplementation((name: string) => {
110
+ if (name === 'auth:forceReauth') return 'true'
111
+ return undefined
112
+ })
113
+
114
+ await handler(fakeEvent())
115
+
116
+ expect(mockDeleteCookie).toHaveBeenCalledWith('auth:forceReauth', { path: '/' })
117
+ expect(lastRedirectUrl(mockSendRedirect)).toContain('prompt=login')
118
+ })
119
+
120
+ it('defaults return_url to / when not provided', async () => {
121
+ mockGetQuery.mockReturnValue({ connector_key: 'customer' })
122
+
123
+ await handler(fakeEvent())
124
+
125
+ const returnUrlCall = mockSetCookie.mock.calls.find((c: unknown[]) => c[0] === 'auth:returnUrl')
126
+ expect(returnUrlCall[1]).toBe('/')
127
+ })
128
+ })
@@ -0,0 +1,63 @@
1
+ import { decodeJwt } from 'jose'
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const { username, password, areaId } = await readBody<{
5
+ username: string
6
+ password: string
7
+ areaId: string
8
+ }>(event)
9
+
10
+ if (!username || !password) {
11
+ throw createError({ statusCode: 400, message: 'Username and password are required' })
12
+ }
13
+
14
+ try {
15
+ const tokens = await exchangeToken({
16
+ grant_type: 'password',
17
+ username,
18
+ password,
19
+ area_id: areaId,
20
+ connector_key: 'stored-users',
21
+ scope: 'openid offline_access',
22
+ })
23
+
24
+ const decoded = decodeJwt(tokens.access_token)
25
+ const claims = extractClaims(decoded)
26
+ const expiresAt = decoded.exp ?? Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
27
+
28
+ await setSessionCookie(event, {
29
+ accessToken: tokens.access_token,
30
+ refreshToken: tokens.refresh_token || '',
31
+ authMethod: 'credentials',
32
+ name: claims.name,
33
+ username: claims.sub,
34
+ customerNumber: claims.customerNumber,
35
+ roles: claims.roles,
36
+ tokenPolicy: {
37
+ accessToken: { expiresAt },
38
+ refreshToken: {
39
+ isEnabled: !!tokens.refresh_token,
40
+ expiresAt: null,
41
+ },
42
+ },
43
+ })
44
+
45
+ deleteCookie(event, 'auth:impersonateToken')
46
+
47
+ return { ok: true }
48
+ } catch (err: unknown) {
49
+ // Translate NORRIQ.Auth error responses to match the existing LoginExceptionResponse shape
50
+ // so the UI error handling in userStore.login() works unchanged
51
+ if (hasErrorData(err)) {
52
+ throw createError({
53
+ statusCode: 401,
54
+ data: err.data,
55
+ })
56
+ }
57
+
58
+ throw createError({
59
+ statusCode: 401,
60
+ data: { type: 'AuthenticationFailed', message: 'Authentication failed' },
61
+ })
62
+ }
63
+ })
@@ -0,0 +1,99 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { User } from 'next-auth'
3
+ import { decodeJwt } from 'jose'
4
+
5
+ function isAuthMethod(value: string): value is User['authMethod'] {
6
+ return value === 'customer' || value === 'norriq'
7
+ }
8
+
9
+ export default defineEventHandler(async (event: H3Event) => {
10
+ const query = getQuery(event)
11
+ const code = typeof query.code === 'string' ? query.code : undefined
12
+ const error = typeof query.error === 'string' ? query.error : undefined
13
+ const errorDescription = typeof query.error_description === 'string' ? query.error_description : undefined
14
+ const connectorKeyRaw = getRouterParam(event, 'connectorKey') || ''
15
+
16
+ if (error) {
17
+ console.error('[oauth-cb] Auth server error:', error, errorDescription)
18
+ return sendRedirect(
19
+ event,
20
+ `/login?error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(errorDescription || '')}`
21
+ )
22
+ }
23
+
24
+ // Validate CSRF state
25
+ const stateParam = typeof query.state === 'string' ? query.state : undefined
26
+ const stateCookie = getCookie(event, 'auth:state')
27
+ deleteCookie(event, 'auth:state')
28
+
29
+ // Retrieve PKCE code_verifier for token exchange
30
+ const codeVerifier = getCookie(event, 'auth:codeVerifier')
31
+ deleteCookie(event, 'auth:codeVerifier')
32
+
33
+ if (!stateParam || !stateCookie || stateParam !== stateCookie) {
34
+ return sendRedirect(event, '/login?error=invalid_state')
35
+ }
36
+
37
+ if (!codeVerifier) {
38
+ return sendRedirect(event, '/login?error=missing_code_verifier')
39
+ }
40
+
41
+ if (!isAuthMethod(connectorKeyRaw)) {
42
+ return sendRedirect(event, '/login?error=invalid_connector')
43
+ }
44
+
45
+ if (!code) {
46
+ return sendRedirect(event, '/login?error=no-code')
47
+ }
48
+
49
+ try {
50
+ const requestUrl = getRequestURL(event)
51
+ const host = getRequestHeader(event, 'host') || requestUrl.host
52
+ const protocol = isSecureRequest(event) ? 'https:' : 'http:'
53
+ const callbackUrl = `${protocol}//${host}/api/auth/oauth-callback/${connectorKeyRaw}`
54
+
55
+ const tokens = await exchangeToken({
56
+ grant_type: 'authorization_code',
57
+ code,
58
+ redirect_uri: callbackUrl,
59
+ code_verifier: codeVerifier,
60
+ })
61
+
62
+ const accessToken = tokens.access_token
63
+ const refreshToken = tokens.refresh_token || ''
64
+ const expiresAt = tokens.expires_in
65
+ ? Math.floor(Date.now() / 1000) + tokens.expires_in
66
+ : Math.floor(Date.now() / 1000) + 3600
67
+
68
+ const decoded = decodeJwt(accessToken)
69
+ const claims = extractClaims(decoded)
70
+
71
+ await setSessionCookie(event, {
72
+ accessToken,
73
+ refreshToken,
74
+ authMethod: connectorKeyRaw,
75
+ name: claims.name,
76
+ username: claims.sub,
77
+ customerNumber: claims.customerNumber,
78
+ roles: claims.roles,
79
+ tokenPolicy: {
80
+ accessToken: { expiresAt },
81
+ refreshToken: {
82
+ isEnabled: !!refreshToken,
83
+ expiresAt,
84
+ },
85
+ },
86
+ })
87
+
88
+ const returnUrl = getCookie(event, 'auth:returnUrl') || '/'
89
+ deleteCookie(event, 'auth:returnUrl')
90
+ const safeReturnUrl = isSafeReturnUrl(returnUrl) ? returnUrl : '/'
91
+
92
+ return sendRedirect(event, safeReturnUrl)
93
+ } catch (err: unknown) {
94
+ const msg = err instanceof Error ? err.message : String(err)
95
+ const data = (err as { data?: unknown })?.data
96
+ console.error('[oauth-cb] Token exchange failed:', msg, data ? JSON.stringify(data) : '')
97
+ return sendRedirect(event, '/login?error=token-exchange-failed')
98
+ }
99
+ })