@orsetra/shared-auth 1.0.12 → 1.1.2

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.
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from "react
4
4
  import { useRouter } from "next/navigation"
5
5
  import { ZitadelAuthService, ZitadelConfig } from "./services/zitadel.auth.service"
6
6
  import { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from "./components/AuthCard"
7
+ import { SessionExpiredModal } from "./components/SessionExpiredModal"
7
8
  import type { User } from "oidc-client-ts"
8
9
 
9
10
  interface ZitadelContextType {
@@ -23,13 +24,16 @@ const ZitadelContext = createContext<ZitadelContextType | undefined>(undefined)
23
24
  interface ZitadelProviderProps {
24
25
  children: ReactNode
25
26
  config: ZitadelConfig
27
+ /** Render the action button shown inside the session-expired modal. Receives the sign-in handler. */
28
+ renderSessionExpiredAction?: (onReauthenticate: () => void) => ReactNode
26
29
  }
27
30
 
28
- export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
31
+ export function ZitadelProvider({ children, config, renderSessionExpiredAction }: ZitadelProviderProps) {
29
32
  const router = useRouter()
30
33
  const [isConfigured, setIsConfigured] = useState(false)
31
34
  const [isLoading, setIsLoading] = useState(true)
32
35
  const [user, setUser] = useState<User | null>(null)
36
+ const [sessionExpired, setSessionExpired] = useState(false)
33
37
 
34
38
  useEffect(() => {
35
39
  // Skip on server-side
@@ -38,8 +42,9 @@ export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
38
42
  const configure = async () => {
39
43
  try {
40
44
  ZitadelAuthService.configureAuth(config)
45
+ ZitadelAuthService.onSessionExpired = () => setSessionExpired(true)
41
46
  setIsConfigured(true)
42
-
47
+
43
48
  const currentUser = await ZitadelAuthService.getUser()
44
49
  setUser(currentUser)
45
50
  } catch (error) {
@@ -130,6 +135,11 @@ export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
130
135
  return (
131
136
  <ZitadelContext.Provider value={value}>
132
137
  {children}
138
+ {sessionExpired && (
139
+ <SessionExpiredModal
140
+ action={renderSessionExpiredAction?.(signIn)}
141
+ />
142
+ )}
133
143
  </ZitadelContext.Provider>
134
144
  )
135
145
  }
@@ -0,0 +1,122 @@
1
+ "use client"
2
+
3
+ import { ReactNode, useEffect, useState } from "react"
4
+
5
+ interface SessionExpiredModalProps {
6
+ /** The action element rendered at the bottom of the modal (e.g. a sign-in button). */
7
+ action: ReactNode
8
+ }
9
+
10
+ export function SessionExpiredModal({ action }: SessionExpiredModalProps) {
11
+ const [isDark, setIsDark] = useState(false)
12
+
13
+ useEffect(() => {
14
+ const checkDarkMode = () => setIsDark(document.documentElement.classList.contains('dark'))
15
+ checkDarkMode()
16
+ const observer = new MutationObserver(checkDarkMode)
17
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
18
+ return () => observer.disconnect()
19
+ }, [])
20
+
21
+ const cardBg = isDark ? 'var(--theme-dark-background-500)' : 'var(--theme-light-background-100)'
22
+ const borderColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
23
+ const textColor = isDark ? 'var(--theme-dark-text)' : 'var(--theme-light-text)'
24
+ const secondaryText = isDark ? 'var(--theme-dark-secondary-text)' : 'var(--theme-light-secondary-text)'
25
+ const warningBg = isDark ? 'rgba(250,163,0,0.1)' : 'rgba(250,163,0,0.08)'
26
+ const warningBorder = 'rgba(250,163,0,0.4)'
27
+
28
+ return (
29
+ <div
30
+ role="dialog"
31
+ aria-modal="true"
32
+ aria-labelledby="session-expired-title"
33
+ style={{
34
+ position: 'fixed',
35
+ inset: 0,
36
+ zIndex: 9999,
37
+ display: 'flex',
38
+ alignItems: 'center',
39
+ justifyContent: 'center',
40
+ backgroundColor: 'rgba(0,0,0,0.6)',
41
+ backdropFilter: 'blur(2px)',
42
+ }}
43
+ >
44
+ <div
45
+ style={{
46
+ width: '440px',
47
+ backgroundColor: cardBg,
48
+ border: '1px solid',
49
+ borderColor,
50
+ padding: '2.5rem',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ alignItems: 'center',
54
+ gap: '1.25rem',
55
+ }}
56
+ >
57
+ {/* Icon */}
58
+ <div
59
+ style={{
60
+ width: '56px',
61
+ height: '56px',
62
+ display: 'flex',
63
+ alignItems: 'center',
64
+ justifyContent: 'center',
65
+ backgroundColor: warningBg,
66
+ border: '1px solid',
67
+ borderColor: warningBorder,
68
+ flexShrink: 0,
69
+ }}
70
+ >
71
+ <svg
72
+ width="28"
73
+ height="28"
74
+ viewBox="0 0 24 24"
75
+ fill="none"
76
+ stroke="#FAA300"
77
+ strokeWidth="1.5"
78
+ strokeLinecap="round"
79
+ strokeLinejoin="round"
80
+ >
81
+ <rect x="3" y="11" width="18" height="11" />
82
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
83
+ <circle cx="12" cy="16" r="1" fill="#FAA300" stroke="none" />
84
+ </svg>
85
+ </div>
86
+
87
+ {/* Title */}
88
+ <h2
89
+ id="session-expired-title"
90
+ style={{
91
+ margin: 0,
92
+ fontSize: '1.125rem',
93
+ fontWeight: 600,
94
+ color: textColor,
95
+ textAlign: 'center',
96
+ }}
97
+ >
98
+ Session Expired
99
+ </h2>
100
+
101
+ {/* Description */}
102
+ <p
103
+ style={{
104
+ margin: 0,
105
+ fontSize: '0.875rem',
106
+ lineHeight: '1.6',
107
+ color: secondaryText,
108
+ textAlign: 'center',
109
+ }}
110
+ >
111
+ Your session has expired and could not be renewed automatically.
112
+ Please sign in again to continue.
113
+ </p>
114
+
115
+ {/* Action slot — provided by the consumer */}
116
+ <div style={{ width: '100%' }}>
117
+ {action}
118
+ </div>
119
+ </div>
120
+ </div>
121
+ )
122
+ }
@@ -1,2 +1,3 @@
1
1
  export { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from './AuthCard'
2
2
  export { AuthCallbackError } from './AuthCallbackError'
3
+ export { SessionExpiredModal } from './SessionExpiredModal'
@@ -29,7 +29,7 @@ export function createAuthConfig(zitadelConfig: ZitadelConfig): UserManagerSetti
29
29
  client_id: `${zitadelConfig.client_id}`,
30
30
  redirect_uri: zitadelConfig.redirect_uri ?? `${baseUrl}/callback`,
31
31
  response_type: zitadelConfig.response_type ?? 'code',
32
- scope: zitadelConfig.scope ?? `openid profile email ${
32
+ scope: zitadelConfig.scope ?? `openid profile email offline_access ${
33
33
  zitadelConfig.project_resource_id
34
34
  ? `urn:zitadel:iam:org:project:id:${zitadelConfig.project_resource_id}:aud urn:zitadel:iam:org:projects:roles access_offline`
35
35
  : ''
@@ -6,6 +6,7 @@ interface SessionUser {
6
6
  access_token: string
7
7
  id_token?: string
8
8
  expires_at?: number
9
+ refresh_token?: string
9
10
  profile: {
10
11
  sub: string
11
12
  email?: string
@@ -25,6 +26,7 @@ interface SessionState {
25
26
  /**
26
27
  * Hook pour récupérer la session utilisateur dans les micro-apps
27
28
  * Lit depuis localStorage (partagé via même domaine)
29
+ * Vérifie automatiquement l'expiration et redirige vers login si nécessaire
28
30
  */
29
31
  export function useSession(): SessionState {
30
32
  const [state, setState] = useState<SessionState>({
@@ -54,6 +56,14 @@ export function useSession(): SessionState {
54
56
  accessToken: userData.access_token,
55
57
  })
56
58
  return
59
+ } else {
60
+ // Token expiré - nettoyer et rediriger
61
+ localStorage.removeItem(storageKey)
62
+
63
+ // Rediriger vers l'app principale pour reconnexion
64
+ const mainAppUrl = process.env.NEXT_PUBLIC_MAIN_APP_URL || window.location.origin
65
+ window.location.href = `${mainAppUrl}/?redirect=${encodeURIComponent(window.location.pathname)}`
66
+ return
57
67
  }
58
68
  }
59
69
  } catch (error) {
package/index.ts CHANGED
@@ -11,5 +11,6 @@ export { SessionProvider, useSession } from './SessionProvider'
11
11
  // Components
12
12
  export { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from './components/AuthCard'
13
13
  export { AuthCallbackError } from './components/AuthCallbackError'
14
+ export { SessionExpiredModal } from './components/SessionExpiredModal'
14
15
 
15
16
  export * from './utils'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-auth",
3
- "version": "1.0.12",
3
+ "version": "1.1.2",
4
4
  "description": "Shared authentication utilities for Orsetra platform using Zitadel",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",
@@ -26,6 +26,8 @@ function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
26
26
  const userManager = new UserManager({
27
27
  userStore: new WebStorageStateStore({ store: window.localStorage }),
28
28
  loadUserInfo: true,
29
+ automaticSilentRenew: true,
30
+ silent_redirect_uri: authConfig.redirect_uri?.replace('/callback', '/silent-refresh'),
29
31
  ...authConfig,
30
32
  });
31
33
 
@@ -37,6 +39,31 @@ function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
37
39
  document.cookie = 'auth_session=; path=/; max-age=0';
38
40
  });
39
41
 
42
+ userManager.events.addUserSignedOut(() => {
43
+ document.cookie = 'auth_session=; path=/; max-age=0';
44
+ Object.keys(localStorage).forEach(key => {
45
+ if (key.startsWith('oidc.user:')) {
46
+ localStorage.removeItem(key);
47
+ }
48
+ });
49
+ });
50
+
51
+ userManager.events.addAccessTokenExpired(() => {
52
+ console.warn(' Access token expired - triggering silent renew');
53
+ // Le renouvellement silencieux est géré automatiquement par automaticSilentRenew
54
+ });
55
+
56
+ userManager.events.addAccessTokenExpiring(() => {
57
+ console.log(' Access token expiring soon - preparing silent renew');
58
+ // Se déclenche environ 60 secondes avant l'expiration
59
+ });
60
+
61
+ userManager.events.addSilentRenewError((error) => {
62
+ console.error(' Silent renew failed:', error);
63
+ document.cookie = 'auth_session=; path=/; max-age=0';
64
+ ZitadelAuthService.onSessionExpired?.();
65
+ });
66
+
40
67
  return {
41
68
  authorize: () => userManager.signinRedirect(),
42
69
  signout: () => userManager.signoutRedirect(),
@@ -46,6 +73,7 @@ function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
46
73
 
47
74
  export class ZitadelAuthService {
48
75
  private static zitadelAuth: ZitadelAuth | null = null;
76
+ static onSessionExpired: (() => void) | null = null;
49
77
 
50
78
  static configureAuth(config: ZitadelConfig) {
51
79
  this.zitadelAuth = createZitadelAuth(config);
@@ -68,7 +96,27 @@ export class ZitadelAuthService {
68
96
 
69
97
  static async signOut() {
70
98
  try {
99
+ // Nettoyer le cookie d'auth
71
100
  document.cookie = 'auth_session=; path=/; max-age=0';
101
+
102
+ // Nettoyer toutes les sessions localStorage (pour les micro-apps)
103
+ const authority = process.env.NEXT_PUBLIC_ZITADEL_AUTHORITY;
104
+ const clientId = process.env.NEXT_PUBLIC_ZITADEL_CLIENT_ID;
105
+
106
+ // Nettoyer la session de l'app courante
107
+ if (authority && clientId) {
108
+ const storageKey = `oidc.user:${authority}:${clientId}`;
109
+ localStorage.removeItem(storageKey);
110
+ }
111
+
112
+ // Nettoyer toutes les autres sessions Zitadel potentielles
113
+ Object.keys(localStorage).forEach(key => {
114
+ if (key.startsWith('oidc.user:')) {
115
+ localStorage.removeItem(key);
116
+ }
117
+ });
118
+
119
+ // Signout redirect vers Zitadel
72
120
  await this.getAuth().signout();
73
121
  } catch (error) {
74
122
  throw this.handleAuthError(error);
@@ -103,6 +151,28 @@ export class ZitadelAuthService {
103
151
  return user !== null && !user.expired;
104
152
  }
105
153
 
154
+ static async getTokenRemainingTime(): Promise<number> {
155
+ const user = await this.getUser();
156
+ if (!user || !user.expires_at) return 0;
157
+ return Math.max(0, user.expires_at - Math.floor(Date.now() / 1000));
158
+ }
159
+
160
+ static async isTokenExpiringSoon(secondsThreshold: number = 60): Promise<boolean> {
161
+ const remainingTime = await this.getTokenRemainingTime();
162
+ return remainingTime > 0 && remainingTime <= secondsThreshold;
163
+ }
164
+
165
+ static async forceTokenRenew(): Promise<User | null> {
166
+ try {
167
+ const userManager = this.getUserManager();
168
+ // Force le renouvellement silencieux manuellement
169
+ return await userManager.signinSilent();
170
+ } catch (error) {
171
+ console.error('Manual token renew failed:', error);
172
+ return null;
173
+ }
174
+ }
175
+
106
176
  static getUserManager(): UserManager {
107
177
  return this.getAuth().userManager;
108
178
  }