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