@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.
- package/ZitadelProvider.tsx +12 -2
- package/components/SessionExpiredModal.tsx +122 -0
- package/components/index.ts +1 -0
- package/config/zitadel.config.ts +1 -1
- package/hooks/useSession.ts +10 -0
- package/index.ts +1 -0
- package/package.json +1 -1
- package/services/zitadel.auth.service.ts +70 -0
package/ZitadelProvider.tsx
CHANGED
|
@@ -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
|
+
}
|
package/components/index.ts
CHANGED
package/config/zitadel.config.ts
CHANGED
|
@@ -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
|
: ''
|
package/hooks/useSession.ts
CHANGED
|
@@ -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
|
@@ -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
|
}
|