@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,37 @@
|
|
|
1
|
+
import type { JWTPayload } from 'jose'
|
|
2
|
+
|
|
3
|
+
interface NorriqJwtClaims {
|
|
4
|
+
sub: string
|
|
5
|
+
name: string
|
|
6
|
+
customerNumber: string
|
|
7
|
+
roles: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Type-safe extraction of custom claims from a decoded JWT. */
|
|
11
|
+
export function extractClaims(decoded: JWTPayload): NorriqJwtClaims {
|
|
12
|
+
const sub = typeof decoded.sub === 'string' ? decoded.sub : ''
|
|
13
|
+
const name = typeof decoded.name === 'string' ? decoded.name : ''
|
|
14
|
+
const customerNumber = typeof decoded.CustomerNumber === 'string' ? decoded.CustomerNumber : ''
|
|
15
|
+
const roles = extractRoles(decoded.role)
|
|
16
|
+
return { sub, name, customerNumber, roles }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractRoles(role: unknown): string[] {
|
|
20
|
+
if (Array.isArray(role)) {
|
|
21
|
+
return role.filter((r): r is string => typeof r === 'string')
|
|
22
|
+
}
|
|
23
|
+
if (typeof role === 'string') {
|
|
24
|
+
return [role]
|
|
25
|
+
}
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Type guard for errors with a `data` property (e.g. FetchError responses). */
|
|
30
|
+
export function hasErrorData(err: unknown): err is { data: Record<string, unknown> } {
|
|
31
|
+
return (
|
|
32
|
+
err !== null &&
|
|
33
|
+
typeof err === 'object' &&
|
|
34
|
+
'data' in err &&
|
|
35
|
+
typeof (err as Record<string, unknown>).data === 'object'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import 'next-auth'
|
|
2
|
+
|
|
3
|
+
declare module 'next-auth' {
|
|
4
|
+
interface User {
|
|
5
|
+
accessToken: string
|
|
6
|
+
refreshToken: string
|
|
7
|
+
authMethod: 'customer' | 'norriq' | 'credentials'
|
|
8
|
+
roles: string[]
|
|
9
|
+
tokenPolicy: {
|
|
10
|
+
accessToken: { expiresAt: number }
|
|
11
|
+
refreshToken: {
|
|
12
|
+
isEnabled: boolean
|
|
13
|
+
expiresAt: number | null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Session {
|
|
19
|
+
user: {
|
|
20
|
+
username: string
|
|
21
|
+
name: string
|
|
22
|
+
customerNumber: string
|
|
23
|
+
roles: string[]
|
|
24
|
+
authInfo: {
|
|
25
|
+
accessToken: string
|
|
26
|
+
refreshToken: string
|
|
27
|
+
tokenPolicy: User['tokenPolicy']
|
|
28
|
+
authMethod: User['authMethod']
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
roles: string[]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
declare module 'next-auth/jwt' {
|
|
36
|
+
interface JWT {
|
|
37
|
+
accessToken: string
|
|
38
|
+
refreshToken: string
|
|
39
|
+
authMethod: 'customer' | 'norriq' | 'credentials'
|
|
40
|
+
name: string
|
|
41
|
+
username: string
|
|
42
|
+
customerNumber: string
|
|
43
|
+
roles: string[]
|
|
44
|
+
tokenPolicy: {
|
|
45
|
+
accessToken: { expiresAt: number }
|
|
46
|
+
refreshToken: {
|
|
47
|
+
isEnabled: boolean
|
|
48
|
+
expiresAt: number | null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { RefreshHandler } from '@sidebase/nuxt-auth'
|
|
2
|
+
|
|
3
|
+
const REFRESH_THRESHOLD_SECONDS = 60 * 5
|
|
4
|
+
const REFRESH_INTERVAL_MS = 60 * 1000 * 2
|
|
5
|
+
|
|
6
|
+
class CustomRefreshHandler implements RefreshHandler {
|
|
7
|
+
private auth: ReturnType<typeof useAuth> | null = null
|
|
8
|
+
private intervalId: ReturnType<typeof setInterval> | null = null
|
|
9
|
+
private boundVisibilityHandler = this.visibilityHandler.bind(this)
|
|
10
|
+
|
|
11
|
+
init(): void {
|
|
12
|
+
this.auth = useAuth()
|
|
13
|
+
|
|
14
|
+
if (typeof document !== 'undefined') {
|
|
15
|
+
document.addEventListener('visibilitychange', this.boundVisibilityHandler)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.intervalId = setInterval(() => this.refreshTokenIfNeeded(), REFRESH_INTERVAL_MS)
|
|
19
|
+
this.refreshTokenIfNeeded()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
destroy(): void {
|
|
23
|
+
if (typeof document !== 'undefined') {
|
|
24
|
+
document.removeEventListener('visibilitychange', this.boundVisibilityHandler)
|
|
25
|
+
}
|
|
26
|
+
if (this.intervalId) {
|
|
27
|
+
clearInterval(this.intervalId)
|
|
28
|
+
this.intervalId = null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private visibilityHandler(): void {
|
|
33
|
+
if (document.visibilityState === 'visible') {
|
|
34
|
+
this.refreshTokenIfNeeded()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async refreshTokenIfNeeded(): Promise<void> {
|
|
39
|
+
if (!this.auth?.data.value) return
|
|
40
|
+
if (this.auth.status.value !== 'authenticated') return
|
|
41
|
+
|
|
42
|
+
const authInfo = this.auth.data.value.user?.authInfo
|
|
43
|
+
if (!authInfo?.tokenPolicy) return
|
|
44
|
+
|
|
45
|
+
if (!authInfo.tokenPolicy.refreshToken?.isEnabled) {
|
|
46
|
+
const expiresIn = authInfo.tokenPolicy.accessToken.expiresAt - Math.floor(Date.now() / 1000)
|
|
47
|
+
if (expiresIn <= 0) {
|
|
48
|
+
this.signOutWithReturnUrl()
|
|
49
|
+
}
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tokenExpiresAt = authInfo.tokenPolicy.accessToken.expiresAt
|
|
54
|
+
const tokenExpiresIn = tokenExpiresAt - Math.floor(Date.now() / 1000)
|
|
55
|
+
|
|
56
|
+
if (tokenExpiresIn > REFRESH_THRESHOLD_SECONDS) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await this.performRefresh()
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Token refresh failed, logging out:', error)
|
|
64
|
+
this.signOutWithReturnUrl()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private signOutWithReturnUrl(): void {
|
|
69
|
+
const currentPath = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'
|
|
70
|
+
this.auth?.signOut({ callbackUrl: currentPath })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async performRefresh(): Promise<void> {
|
|
74
|
+
const response = await $fetch<{ success: boolean }>('/api/auth/refresh-token', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (!response.success) {
|
|
79
|
+
throw new Error('Token refresh failed')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await this.auth?.getSession()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default new CustomRefreshHandler()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { consumeForceReauth } from './forceReauth'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the authorize URL and navigates to it.
|
|
5
|
+
* Auto-redirects to CIAM. Use ?connector=norriq for NORRIQ staff login.
|
|
6
|
+
*
|
|
7
|
+
* If the route has an `error` query param (from a failed OAuth callback),
|
|
8
|
+
* returns the error instead of redirecting — preventing an infinite loop.
|
|
9
|
+
*/
|
|
10
|
+
export function useAuthorizeRedirect(defaultReturnUrl: string) {
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
|
|
13
|
+
const error = typeof route.query.error === 'string' ? route.query.error : undefined
|
|
14
|
+
const errorDescription = typeof route.query.error_description === 'string' ? route.query.error_description : undefined
|
|
15
|
+
|
|
16
|
+
if (error) {
|
|
17
|
+
return { error, errorDescription }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const returnUrl = typeof route.query.redirect === 'string' ? route.query.redirect : defaultReturnUrl
|
|
21
|
+
const connector = route.query.connector === 'norriq' ? 'norriq' : 'customer'
|
|
22
|
+
|
|
23
|
+
const params = new URLSearchParams({
|
|
24
|
+
connector_key: connector,
|
|
25
|
+
return_url: returnUrl,
|
|
26
|
+
})
|
|
27
|
+
if (consumeForceReauth(route.query.forceReauthentication)) {
|
|
28
|
+
params.set('force_reauthentication', 'true')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
navigateTo(`/api/auth/authorize?${params}`, { external: true })
|
|
32
|
+
return { error: undefined, errorDescription: undefined }
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const FORCE_REAUTHENTICATION_COOKIE = 'auth:forceReauth'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sets a cookie that forces CIAM reauthentication on the next login.
|
|
5
|
+
* Call this on sign-out so the user gets a fresh credential prompt
|
|
6
|
+
* instead of CIAM auto-signing them back in.
|
|
7
|
+
*
|
|
8
|
+
* Uses document.cookie directly because useCookie() batches writes
|
|
9
|
+
* reactively — signOut() can navigate away before the write flushes.
|
|
10
|
+
*/
|
|
11
|
+
export function setForceReauth() {
|
|
12
|
+
if (import.meta.client) {
|
|
13
|
+
const maxAge = 60 * 10
|
|
14
|
+
document.cookie = `${FORCE_REAUTHENTICATION_COOKIE}=true; path=/; max-age=${maxAge}`
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks and consumes the force-reauthentication cookie.
|
|
20
|
+
* Returns true if reauthentication should be forced (from cookie or query param).
|
|
21
|
+
* Clears the cookie after reading.
|
|
22
|
+
*/
|
|
23
|
+
export function consumeForceReauth(queryValue: unknown): boolean {
|
|
24
|
+
const cookie = useCookie(FORCE_REAUTHENTICATION_COOKIE)
|
|
25
|
+
const force = queryValue === 'true' || cookie.value === 'true'
|
|
26
|
+
if (force) cookie.value = null
|
|
27
|
+
return force
|
|
28
|
+
}
|