@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.
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { EventHandler } from 'h3'
3
+ import { fakeEvent, fakeJwt, stubCreateError, lastRedirectUrl } from '../../../test-helpers'
4
+
5
+ const mockGetQuery = vi.fn()
6
+ const mockGetCookie = vi.fn()
7
+ const mockDeleteCookie = vi.fn()
8
+ const mockSendRedirect = vi.fn()
9
+ const mockGetRouterParam = vi.fn()
10
+ const mockGetRequestHeader = vi.fn()
11
+ const mockExchangeToken = vi.fn()
12
+ const mockSetSessionCookie = vi.fn()
13
+
14
+ vi.stubGlobal('defineEventHandler', <T>(fn: T): T => fn)
15
+ stubCreateError()
16
+ vi.stubGlobal('getQuery', (event: unknown) => mockGetQuery(event))
17
+ vi.stubGlobal('getCookie', (_event: unknown, name: string) => mockGetCookie(name))
18
+ vi.stubGlobal('deleteCookie', (_event: unknown, name: string) => mockDeleteCookie(name))
19
+ vi.stubGlobal('setCookie', vi.fn())
20
+ vi.stubGlobal('sendRedirect', (_event: unknown, url: string) => mockSendRedirect(url))
21
+ vi.stubGlobal('getRouterParam', () => mockGetRouterParam())
22
+ vi.stubGlobal('getRequestURL', () => new URL('https://app.example.com/api/auth/oauth-callback/customer'))
23
+ vi.stubGlobal('getRequestHeader', (_event: unknown, header: string) => mockGetRequestHeader(header))
24
+ vi.stubGlobal('isSecureRequest', () => true)
25
+ vi.stubGlobal('isSafeReturnUrl', (url: string) => url.startsWith('/') && !url.startsWith('//'))
26
+ vi.stubGlobal('exchangeToken', (...args: unknown[]) => mockExchangeToken(...args))
27
+ vi.stubGlobal('setSessionCookie', (...args: unknown[]) => mockSetSessionCookie(...args))
28
+ vi.stubGlobal('extractClaims', (decoded: Record<string, unknown>) => ({
29
+ sub: decoded.sub || '',
30
+ name: decoded.name || '',
31
+ customerNumber: decoded.CustomerNumber || '',
32
+ roles: [],
33
+ }))
34
+
35
+ function withCookies(cookies: Record<string, string>) {
36
+ mockGetCookie.mockImplementation((name: string) => cookies[name])
37
+ }
38
+
39
+ describe('oauth-callback', () => {
40
+ let handler: EventHandler
41
+
42
+ beforeEach(async () => {
43
+ vi.clearAllMocks()
44
+ mockGetRequestHeader.mockImplementation((header: string) => {
45
+ if (header === 'host') return 'app.example.com'
46
+ return undefined
47
+ })
48
+
49
+ vi.resetModules()
50
+ const mod = await import('./[connectorKey]')
51
+ handler = mod.default
52
+ })
53
+
54
+ it('redirects to login on auth server error', async () => {
55
+ mockGetQuery.mockReturnValue({ error: 'access_denied', error_description: 'User cancelled' })
56
+ mockGetRouterParam.mockReturnValue('customer')
57
+
58
+ await handler(fakeEvent())
59
+
60
+ const url = lastRedirectUrl(mockSendRedirect)
61
+ expect(url).toContain('/login?error=access_denied')
62
+ expect(url).toContain('error_description=User%20cancelled')
63
+ })
64
+
65
+ it('rejects mismatched state (CSRF protection)', async () => {
66
+ mockGetQuery.mockReturnValue({ code: 'auth-code', state: 'state-a' })
67
+ mockGetRouterParam.mockReturnValue('customer')
68
+ withCookies({ 'auth:state': 'state-b', 'auth:codeVerifier': 'verifier' })
69
+
70
+ await handler(fakeEvent())
71
+
72
+ expect(mockSendRedirect).toHaveBeenCalledWith('/login?error=invalid_state')
73
+ })
74
+
75
+ it('rejects missing code verifier', async () => {
76
+ mockGetQuery.mockReturnValue({ code: 'auth-code', state: 'matching-state' })
77
+ mockGetRouterParam.mockReturnValue('customer')
78
+ withCookies({ 'auth:state': 'matching-state' })
79
+
80
+ await handler(fakeEvent())
81
+
82
+ expect(mockSendRedirect).toHaveBeenCalledWith('/login?error=missing_code_verifier')
83
+ })
84
+
85
+ it('rejects invalid connector key', async () => {
86
+ mockGetQuery.mockReturnValue({ code: 'auth-code', state: 'ok' })
87
+ mockGetRouterParam.mockReturnValue('evil-connector')
88
+ withCookies({ 'auth:state': 'ok', 'auth:codeVerifier': 'verifier' })
89
+
90
+ await handler(fakeEvent())
91
+
92
+ expect(mockSendRedirect).toHaveBeenCalledWith('/login?error=invalid_connector')
93
+ })
94
+
95
+ it('exchanges code for tokens and sets session cookie on success', async () => {
96
+ const jwt = fakeJwt({ sub: 'user@test.com', name: 'Test User', CustomerNumber: 'C-100' })
97
+ mockGetQuery.mockReturnValue({ code: 'valid-code', state: 'valid-state' })
98
+ mockGetRouterParam.mockReturnValue('customer')
99
+ withCookies({ 'auth:state': 'valid-state', 'auth:codeVerifier': 'test-verifier', 'auth:returnUrl': '/dashboard' })
100
+ mockExchangeToken.mockResolvedValue({
101
+ access_token: jwt,
102
+ refresh_token: 'refresh-tok',
103
+ expires_in: 3600,
104
+ token_type: 'Bearer',
105
+ })
106
+ mockSetSessionCookie.mockResolvedValue(undefined)
107
+
108
+ await handler(fakeEvent())
109
+
110
+ expect(mockExchangeToken).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ grant_type: 'authorization_code',
113
+ code: 'valid-code',
114
+ code_verifier: 'test-verifier',
115
+ })
116
+ )
117
+ expect(mockSetSessionCookie).toHaveBeenCalledWith(
118
+ expect.anything(),
119
+ expect.objectContaining({
120
+ accessToken: jwt,
121
+ refreshToken: 'refresh-tok',
122
+ authMethod: 'customer',
123
+ })
124
+ )
125
+ expect(mockSendRedirect).toHaveBeenCalledWith('/dashboard')
126
+ })
127
+
128
+ it('falls back to / when returnUrl cookie is missing', async () => {
129
+ mockGetQuery.mockReturnValue({ code: 'code', state: 'state' })
130
+ mockGetRouterParam.mockReturnValue('customer')
131
+ withCookies({ 'auth:state': 'state', 'auth:codeVerifier': 'verifier' })
132
+ mockExchangeToken.mockResolvedValue({
133
+ access_token: fakeJwt(),
134
+ refresh_token: '',
135
+ expires_in: 3600,
136
+ token_type: 'Bearer',
137
+ })
138
+ mockSetSessionCookie.mockResolvedValue(undefined)
139
+
140
+ await handler(fakeEvent())
141
+
142
+ expect(mockSendRedirect).toHaveBeenCalledWith('/')
143
+ })
144
+
145
+ it('sanitizes unsafe returnUrl to /', async () => {
146
+ mockGetQuery.mockReturnValue({ code: 'code', state: 'state' })
147
+ mockGetRouterParam.mockReturnValue('customer')
148
+ withCookies({ 'auth:state': 'state', 'auth:codeVerifier': 'verifier', 'auth:returnUrl': '//evil.com' })
149
+ mockExchangeToken.mockResolvedValue({
150
+ access_token: fakeJwt(),
151
+ refresh_token: '',
152
+ expires_in: 3600,
153
+ token_type: 'Bearer',
154
+ })
155
+ mockSetSessionCookie.mockResolvedValue(undefined)
156
+
157
+ await handler(fakeEvent())
158
+
159
+ expect(mockSendRedirect).toHaveBeenCalledWith('/')
160
+ })
161
+
162
+ it('redirects to login on token exchange failure', async () => {
163
+ mockGetQuery.mockReturnValue({ code: 'code', state: 'state' })
164
+ mockGetRouterParam.mockReturnValue('customer')
165
+ withCookies({ 'auth:state': 'state', 'auth:codeVerifier': 'verifier' })
166
+ mockExchangeToken.mockRejectedValue(new Error('Network error'))
167
+
168
+ await handler(fakeEvent())
169
+
170
+ expect(mockSendRedirect).toHaveBeenCalledWith('/login?error=token-exchange-failed')
171
+ })
172
+
173
+ it('cleans up state and codeVerifier cookies', async () => {
174
+ mockGetQuery.mockReturnValue({ code: 'code', state: 'state' })
175
+ mockGetRouterParam.mockReturnValue('customer')
176
+ withCookies({ 'auth:state': 'state', 'auth:codeVerifier': 'verifier' })
177
+ mockExchangeToken.mockResolvedValue({
178
+ access_token: fakeJwt(),
179
+ expires_in: 3600,
180
+ token_type: 'Bearer',
181
+ })
182
+ mockSetSessionCookie.mockResolvedValue(undefined)
183
+
184
+ await handler(fakeEvent())
185
+
186
+ expect(mockDeleteCookie).toHaveBeenCalledWith('auth:state')
187
+ expect(mockDeleteCookie).toHaveBeenCalledWith('auth:codeVerifier')
188
+ })
189
+ })
@@ -0,0 +1,48 @@
1
+ import type { JWT } from 'next-auth/jwt'
2
+ import { decodeJwt } from 'jose'
3
+ import { decodeSessionCookie, exchangeToken, setSessionCookie } from '../../utils/authSession'
4
+ import { extractClaims } from '../../utils/jwtClaims'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const currentSession = await decodeSessionCookie(event)
8
+ if (!currentSession) {
9
+ throw createError({ statusCode: 401, message: 'No session' })
10
+ }
11
+
12
+ if (!currentSession.refreshToken) {
13
+ throw createError({ statusCode: 400, message: 'No refresh token' })
14
+ }
15
+
16
+ const tokenResponse = await exchangeToken({
17
+ grant_type: 'refresh_token',
18
+ refresh_token: currentSession.refreshToken,
19
+ })
20
+
21
+ const expiresAt = tokenResponse.expires_in
22
+ ? Math.floor(Date.now() / 1000) + tokenResponse.expires_in
23
+ : Math.floor(Date.now() / 1000) + 3600
24
+
25
+ const decoded = decodeJwt(tokenResponse.access_token)
26
+ const claims = extractClaims(decoded)
27
+
28
+ const updatedSession: JWT = {
29
+ ...currentSession,
30
+ accessToken: tokenResponse.access_token,
31
+ refreshToken: tokenResponse.refresh_token || currentSession.refreshToken,
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: true,
40
+ expiresAt,
41
+ },
42
+ },
43
+ }
44
+
45
+ await setSessionCookie(event, updatedSession)
46
+
47
+ return { success: true }
48
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import type { EventHandler } from 'h3'
3
+ import type { JWT } from 'next-auth/jwt'
4
+ import { fakeEvent, fakeJwt, stubCreateError } from '../../test-helpers'
5
+
6
+ const mockDecodeSessionCookie = vi.fn()
7
+ const mockExchangeToken = vi.fn()
8
+ const mockSetSessionCookie = vi.fn()
9
+
10
+ vi.mock('../../utils/authSession', () => ({
11
+ decodeSessionCookie: (...args: unknown[]) => mockDecodeSessionCookie(...args),
12
+ exchangeToken: (...args: unknown[]) => mockExchangeToken(...args),
13
+ setSessionCookie: (...args: unknown[]) => mockSetSessionCookie(...args),
14
+ }))
15
+
16
+ vi.mock('h3', async () => {
17
+ const actual = await vi.importActual('h3')
18
+ return { ...actual }
19
+ })
20
+
21
+ vi.stubGlobal('defineEventHandler', <T>(fn: T): T => fn)
22
+ stubCreateError()
23
+
24
+ describe('refresh-token.post', () => {
25
+ let handler: EventHandler
26
+
27
+ beforeEach(async () => {
28
+ vi.clearAllMocks()
29
+ const mod = await import('./refresh-token.post')
30
+ handler = mod.default
31
+ })
32
+
33
+ it('throws 401 when no session cookie exists', async () => {
34
+ mockDecodeSessionCookie.mockResolvedValue(null)
35
+
36
+ const err = await handler(fakeEvent()).catch((e: Error & { statusCode?: number }) => e)
37
+ expect(err.message).toBe('No session')
38
+ expect(err.statusCode).toBe(401)
39
+ })
40
+
41
+ it('throws 400 when session has no refresh token', async () => {
42
+ mockDecodeSessionCookie.mockResolvedValue({
43
+ accessToken: 'test-access',
44
+ refreshToken: '',
45
+ authMethod: 'customer',
46
+ name: '',
47
+ username: '',
48
+ customerNumber: '',
49
+ roles: [],
50
+ tokenPolicy: {
51
+ accessToken: { expiresAt: 0 },
52
+ refreshToken: { isEnabled: false, expiresAt: 0 },
53
+ },
54
+ } satisfies JWT)
55
+
56
+ const err = await handler(fakeEvent()).catch((e: Error & { statusCode?: number }) => e)
57
+ expect(err.message).toBe('No refresh token')
58
+ expect(err.statusCode).toBe(400)
59
+ })
60
+
61
+ it('successfully refreshes token and updates cookie', async () => {
62
+ const session: JWT = {
63
+ accessToken: 'old-access-token',
64
+ refreshToken: 'old-refresh-token',
65
+ authMethod: 'customer',
66
+ name: 'Test User',
67
+ username: 'test@example.com',
68
+ customerNumber: 'C-001',
69
+ roles: [],
70
+ tokenPolicy: {
71
+ accessToken: { expiresAt: Math.floor(Date.now() / 1000) + 100 },
72
+ refreshToken: { isEnabled: true, expiresAt: Math.floor(Date.now() / 1000) + 100 },
73
+ },
74
+ }
75
+
76
+ const newAccessToken = fakeJwt({ sub: 'test@example.com', name: 'Test User', CustomerNumber: 'C-001' })
77
+ mockDecodeSessionCookie.mockResolvedValue(session)
78
+ mockExchangeToken.mockResolvedValue({
79
+ access_token: newAccessToken,
80
+ refresh_token: 'new-refresh-token',
81
+ expires_in: 3600,
82
+ token_type: 'Bearer',
83
+ })
84
+ mockSetSessionCookie.mockResolvedValue(undefined)
85
+
86
+ const event = fakeEvent()
87
+ const result = await handler(event)
88
+
89
+ expect(result).toEqual({ success: true })
90
+ expect(mockExchangeToken).toHaveBeenCalledWith({
91
+ grant_type: 'refresh_token',
92
+ refresh_token: 'old-refresh-token',
93
+ })
94
+ expect(mockSetSessionCookie).toHaveBeenCalledWith(
95
+ event,
96
+ expect.objectContaining({
97
+ accessToken: newAccessToken,
98
+ refreshToken: 'new-refresh-token',
99
+ })
100
+ )
101
+ })
102
+
103
+ it('keeps existing refresh token when server does not return a new one', async () => {
104
+ const session: JWT = {
105
+ accessToken: 'old-access',
106
+ refreshToken: 'existing-refresh-token',
107
+ authMethod: 'customer',
108
+ name: 'Test User',
109
+ username: 'test@example.com',
110
+ customerNumber: 'C-001',
111
+ roles: [],
112
+ tokenPolicy: {
113
+ accessToken: { expiresAt: 0 },
114
+ refreshToken: { isEnabled: true, expiresAt: 0 },
115
+ },
116
+ }
117
+
118
+ mockDecodeSessionCookie.mockResolvedValue(session)
119
+ mockExchangeToken.mockResolvedValue({
120
+ access_token: fakeJwt({ sub: 'test@example.com', name: 'Test User', CustomerNumber: 'C-001' }),
121
+ expires_in: 3600,
122
+ token_type: 'Bearer',
123
+ })
124
+ mockSetSessionCookie.mockResolvedValue(undefined)
125
+
126
+ const event = fakeEvent()
127
+ await handler(event)
128
+
129
+ expect(mockSetSessionCookie).toHaveBeenCalledWith(
130
+ event,
131
+ expect.objectContaining({ refreshToken: 'existing-refresh-token' })
132
+ )
133
+ })
134
+ })
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Validates that all required environment variables are set at server startup.
3
+ * Fails fast with a clear error instead of crashing later with cryptic messages.
4
+ * Only enforced in production — local dev uses .env files with defaults.
5
+ */
6
+ export default defineNitroPlugin(() => {
7
+ if (process.env.NODE_ENV !== 'production') return
8
+
9
+ const required: Record<string, string | undefined> = {
10
+ NUXT_AUTH_URL: process.env.NUXT_AUTH_URL,
11
+ NUXT_PUBLIC_AUTH_URL: process.env.NUXT_PUBLIC_AUTH_URL,
12
+ NUXT_PUBLIC_AUTH_CLIENT_ID: process.env.NUXT_PUBLIC_AUTH_CLIENT_ID,
13
+ NUXT_AUTH_SECRET: process.env.NUXT_AUTH_SECRET,
14
+ AUTH_ORIGIN: process.env.AUTH_ORIGIN,
15
+ }
16
+
17
+ const missing = Object.entries(required)
18
+ .filter(([, value]) => !value)
19
+ .map(([key]) => key)
20
+
21
+ if (missing.length > 0) {
22
+ throw new Error(`Missing required environment variables:\n ${missing.join('\n ')}`)
23
+ }
24
+ })
@@ -0,0 +1,54 @@
1
+ import { vi } from 'vitest'
2
+ import { base64url } from 'jose'
3
+ import type { H3Event } from 'h3'
4
+
5
+ export function fakeEvent(overrides: Partial<H3Event> = {}): H3Event {
6
+ return { method: 'GET', ...overrides } as H3Event
7
+ }
8
+
9
+ export function fakeJwt(claims: Record<string, unknown> = {}): string {
10
+ const header = base64url.encode(JSON.stringify({ alg: 'none' }))
11
+ const payload = base64url.encode(
12
+ JSON.stringify({
13
+ sub: 'user@test.com',
14
+ name: 'Test',
15
+ exp: Math.floor(Date.now() / 1000) + 3600,
16
+ ...claims,
17
+ })
18
+ )
19
+ return `${header}.${payload}.`
20
+ }
21
+
22
+ interface HttpError extends Error {
23
+ statusCode: number
24
+ }
25
+
26
+ export function stubCreateError() {
27
+ vi.stubGlobal('createError', (opts: { statusCode: number; message?: string; statusMessage?: string }) => {
28
+ const error = new Error(opts.message || opts.statusMessage) as HttpError
29
+ error.statusCode = opts.statusCode
30
+ return error
31
+ })
32
+ }
33
+
34
+ export function lastRedirectUrl(mock: ReturnType<typeof vi.fn>): string {
35
+ return mock.mock.calls[0][0]
36
+ }
37
+
38
+ export function fetchCallUrl(mock: ReturnType<typeof vi.fn>): string {
39
+ return mock.mock.calls[0][0]
40
+ }
41
+
42
+ export function fetchCallHeaders(mock: ReturnType<typeof vi.fn>): Record<string, string> {
43
+ return mock.mock.calls[0][1].headers
44
+ }
45
+
46
+ export async function catchHttpError(fn: () => unknown): Promise<HttpError> {
47
+ try {
48
+ await fn()
49
+ throw new Error('Expected function to throw')
50
+ } catch (err) {
51
+ if (err instanceof Error && 'statusCode' in err) return err as HttpError
52
+ throw err
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { isSafeReturnUrl } from './authSession'
3
+
4
+ describe('isSafeReturnUrl', () => {
5
+ it('allows simple relative paths', () => {
6
+ expect(isSafeReturnUrl('/')).toBe(true)
7
+ expect(isSafeReturnUrl('/dashboard')).toBe(true)
8
+ expect(isSafeReturnUrl('/customer/orders')).toBe(true)
9
+ expect(isSafeReturnUrl('/path?query=1')).toBe(true)
10
+ expect(isSafeReturnUrl('/path#fragment')).toBe(true)
11
+ })
12
+
13
+ it('blocks protocol-relative URLs (open redirect)', () => {
14
+ expect(isSafeReturnUrl('//evil.com')).toBe(false)
15
+ expect(isSafeReturnUrl('//evil.com/path')).toBe(false)
16
+ })
17
+
18
+ it('blocks absolute URLs', () => {
19
+ expect(isSafeReturnUrl('https://evil.com')).toBe(false)
20
+ expect(isSafeReturnUrl('http://evil.com')).toBe(false)
21
+ expect(isSafeReturnUrl('ftp://evil.com')).toBe(false)
22
+ })
23
+
24
+ it('blocks backslash tricks', () => {
25
+ expect(isSafeReturnUrl('/\\evil.com')).toBe(false)
26
+ expect(isSafeReturnUrl('/path\\to\\evil')).toBe(false)
27
+ })
28
+
29
+ it('blocks empty and non-slash-prefixed strings', () => {
30
+ expect(isSafeReturnUrl('')).toBe(false)
31
+ expect(isSafeReturnUrl('evil.com')).toBe(false)
32
+ expect(isSafeReturnUrl('javascript:alert(1)')).toBe(false)
33
+ })
34
+ })
@@ -0,0 +1,88 @@
1
+ import { encode, decode } from 'next-auth/jwt'
2
+ import type { JWT } from 'next-auth/jwt'
3
+ import type { H3Event } from 'h3'
4
+
5
+ /**
6
+ * Detects whether the request was made over HTTPS,
7
+ * accounting for reverse proxies that set x-forwarded-proto.
8
+ */
9
+ export function isSecureRequest(event: H3Event): boolean {
10
+ const forwardedProto = getRequestHeader(event, 'x-forwarded-proto')
11
+ if (forwardedProto) return forwardedProto === 'https'
12
+ return getRequestURL(event).protocol === 'https:'
13
+ }
14
+
15
+ export function getSessionCookieName(event: H3Event): string {
16
+ return isSecureRequest(event) ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
17
+ }
18
+
19
+ export function decodeSessionCookie(event: H3Event): Promise<JWT | null> {
20
+ const cookieName = getSessionCookieName(event)
21
+ const token = getCookie(event, cookieName)
22
+ if (!token) return Promise.resolve(null)
23
+
24
+ const secret = useRuntimeConfig().authSecret
25
+ return decode({ token, secret })
26
+ }
27
+
28
+ export async function setSessionCookie(event: H3Event, session: JWT): Promise<void> {
29
+ const secret = useRuntimeConfig().authSecret
30
+ const cookieName = getSessionCookieName(event)
31
+ const secure = isSecureRequest(event)
32
+
33
+ const encoded = await encode({ token: session, secret })
34
+
35
+ setCookie(event, cookieName, encoded, {
36
+ httpOnly: true,
37
+ secure,
38
+ sameSite: 'lax',
39
+ path: '/',
40
+ maxAge: 60 * 60 * 24,
41
+ })
42
+ }
43
+
44
+ /** Validates that a URL is a relative path, preventing open redirects. */
45
+ export function isSafeReturnUrl(url: string): boolean {
46
+ return url.startsWith('/') && !url.startsWith('//') && !url.includes('\\')
47
+ }
48
+
49
+ /**
50
+ * Exchanges an authorization code or other grant at the NORRIQ.Auth /connect/token endpoint.
51
+ */
52
+ export function exchangeToken(
53
+ params: Record<string, string>,
54
+ options?: { headers?: Record<string, string> }
55
+ ): Promise<{
56
+ access_token: string
57
+ refresh_token?: string
58
+ expires_in?: number
59
+ token_type: string
60
+ }> {
61
+ const config = useRuntimeConfig()
62
+ const authUrl = config.authUrlInternal || config.authUrl
63
+ const clientId = config.public.authClientId
64
+ if (!authUrl || !clientId) {
65
+ throw new Error('Auth environment variables not configured (NUXT_AUTH_URL, NUXT_PUBLIC_AUTH_CLIENT_ID)')
66
+ }
67
+
68
+ const clientSecret = config.authClientSecret || ''
69
+
70
+ const body: Record<string, string> = {
71
+ client_id: clientId,
72
+ ...params,
73
+ }
74
+ if (clientSecret) {
75
+ body.client_secret = clientSecret
76
+ }
77
+
78
+ const url: string = `${authUrl}/connect/token`
79
+ // @ts-expect-error Nuxt route type inference causes excessive stack depth with $fetch
80
+ return $fetch(url, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/x-www-form-urlencoded',
84
+ ...options?.headers,
85
+ },
86
+ body: new URLSearchParams(body).toString(),
87
+ })
88
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { extractClaims, hasErrorData } from './jwtClaims'
3
+
4
+ describe('extractClaims', () => {
5
+ it('extracts all claims from a well-formed JWT payload', () => {
6
+ const result = extractClaims({
7
+ sub: 'user-123',
8
+ name: 'John Doe',
9
+ CustomerNumber: 'CUST-456',
10
+ role: ['Admin', 'User'],
11
+ })
12
+
13
+ expect(result).toEqual({
14
+ sub: 'user-123',
15
+ name: 'John Doe',
16
+ customerNumber: 'CUST-456',
17
+ roles: ['Admin', 'User'],
18
+ })
19
+ })
20
+
21
+ it('handles single role as string', () => {
22
+ const result = extractClaims({
23
+ sub: 'user-1',
24
+ name: 'Jane',
25
+ CustomerNumber: 'C1',
26
+ role: 'Admin',
27
+ })
28
+
29
+ expect(result.roles).toEqual(['Admin'])
30
+ })
31
+
32
+ it('returns empty strings and array for missing claims', () => {
33
+ const result = extractClaims({})
34
+
35
+ expect(result).toEqual({
36
+ sub: '',
37
+ name: '',
38
+ customerNumber: '',
39
+ roles: [],
40
+ })
41
+ })
42
+
43
+ it('ignores non-string values in role arrays', () => {
44
+ const result = extractClaims({
45
+ role: ['Admin', 42, null, 'User', undefined],
46
+ })
47
+
48
+ expect(result.roles).toEqual(['Admin', 'User'])
49
+ })
50
+
51
+ it('returns empty roles for non-string, non-array role', () => {
52
+ expect(extractClaims({ role: 42 }).roles).toEqual([])
53
+ expect(extractClaims({ role: null }).roles).toEqual([])
54
+ expect(extractClaims({ role: true }).roles).toEqual([])
55
+ })
56
+ })
57
+
58
+ describe('hasErrorData', () => {
59
+ it('returns true for objects with a data property', () => {
60
+ expect(hasErrorData({ data: { type: 'SomeError' } })).toBe(true)
61
+ })
62
+
63
+ it('returns false for null', () => {
64
+ expect(hasErrorData(null)).toBe(false)
65
+ })
66
+
67
+ it('returns false for primitives', () => {
68
+ expect(hasErrorData('string')).toBe(false)
69
+ expect(hasErrorData(42)).toBe(false)
70
+ expect(hasErrorData(undefined)).toBe(false)
71
+ })
72
+
73
+ it('returns false for objects without data', () => {
74
+ expect(hasErrorData({ message: 'error' })).toBe(false)
75
+ })
76
+
77
+ it('returns false when data is not an object', () => {
78
+ expect(hasErrorData({ data: 'string' })).toBe(false)
79
+ expect(hasErrorData({ data: 42 })).toBe(false)
80
+ })
81
+ })