@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 +8 -0
- package/README.md +44 -0
- package/nuxt.config.ts +49 -0
- package/package.json +40 -0
- package/server/api/auth/[...].ts +34 -0
- package/server/api/auth/authorize.get.ts +87 -0
- package/server/api/auth/authorize.test.ts +128 -0
- package/server/api/auth/credentials-login.post.ts +63 -0
- package/server/api/auth/oauth-callback/[connectorKey].ts +99 -0
- package/server/api/auth/oauth-callback/oauth-callback.test.ts +189 -0
- package/server/api/auth/refresh-token.post.ts +48 -0
- package/server/api/auth/refresh-token.test.ts +134 -0
- package/server/plugins/validate-env.ts +24 -0
- package/server/test-helpers.ts +54 -0
- package/server/utils/authSession.test.ts +34 -0
- package/server/utils/authSession.ts +88 -0
- package/server/utils/jwtClaims.test.ts +81 -0
- package/server/utils/jwtClaims.ts +37 -0
- package/types/next-auth.d.ts +52 -0
- package/utils/auth/AuthRefreshHandler.ts +86 -0
- package/utils/auth/authorizeRedirect.ts +33 -0
- package/utils/auth/forceReauth.ts +28 -0
|
@@ -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
|
+
})
|