@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
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
|
+
})
|