@orsetra/shared-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/ProtectedRoute.tsx +109 -0
- package/README.md +143 -0
- package/ZitadelProvider.tsx +140 -0
- package/components/AuthCard.tsx +143 -0
- package/config/zitadel.config.ts +43 -0
- package/index.ts +7 -0
- package/package.json +52 -0
- package/services/zitadel.auth.service.ts +93 -0
- package/utils/index.ts +2 -0
- package/utils/token-validator.ts +54 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ReactNode } from "react"
|
|
4
|
+
import { useZitadel } from "./ZitadelProvider"
|
|
5
|
+
import { useRouter } from "next/navigation"
|
|
6
|
+
|
|
7
|
+
interface ProtectedRouteProps {
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/**
|
|
10
|
+
* URL de redirection si l'utilisateur n'est pas authentifié
|
|
11
|
+
* Par défaut: affiche un écran de connexion
|
|
12
|
+
*/
|
|
13
|
+
redirectTo?: string
|
|
14
|
+
/**
|
|
15
|
+
* Fonction de vérification personnalisée (ex: vérifier un rôle)
|
|
16
|
+
*/
|
|
17
|
+
authorize?: (user: any) => boolean
|
|
18
|
+
/**
|
|
19
|
+
* Message personnalisé si l'autorisation échoue
|
|
20
|
+
*/
|
|
21
|
+
unauthorizedMessage?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Composant pour protéger une route ou une section spécifique
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* // Protection simple
|
|
29
|
+
* <ProtectedRoute>
|
|
30
|
+
* <AdminPanel />
|
|
31
|
+
* </ProtectedRoute>
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Avec vérification de rôle
|
|
35
|
+
* <ProtectedRoute
|
|
36
|
+
* authorize={(user) => user.profile?.roles?.includes('admin')}
|
|
37
|
+
* unauthorizedMessage="Accès réservé aux administrateurs"
|
|
38
|
+
* >
|
|
39
|
+
* <AdminPanel />
|
|
40
|
+
* </ProtectedRoute>
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // Avec redirection
|
|
44
|
+
* <ProtectedRoute redirectTo="/login">
|
|
45
|
+
* <Dashboard />
|
|
46
|
+
* </ProtectedRoute>
|
|
47
|
+
*/
|
|
48
|
+
export function ProtectedRoute({
|
|
49
|
+
children,
|
|
50
|
+
redirectTo,
|
|
51
|
+
authorize,
|
|
52
|
+
unauthorizedMessage = "Vous n'avez pas les permissions nécessaires pour accéder à cette page.",
|
|
53
|
+
}: ProtectedRouteProps) {
|
|
54
|
+
const { user, isLoading, signIn } = useZitadel()
|
|
55
|
+
const router = useRouter()
|
|
56
|
+
|
|
57
|
+
// Chargement
|
|
58
|
+
if (isLoading) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
61
|
+
<div className="text-center">
|
|
62
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
|
63
|
+
<p className="text-gray-600">Vérification des permissions...</p>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Non authentifié
|
|
70
|
+
if (!user) {
|
|
71
|
+
if (redirectTo) {
|
|
72
|
+
router.push(redirectTo)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex items-center justify-center min-h-[400px] bg-gray-50">
|
|
78
|
+
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center">
|
|
79
|
+
<h2 className="text-xl font-bold mb-4">Authentification requise</h2>
|
|
80
|
+
<p className="text-gray-600 mb-6">
|
|
81
|
+
Vous devez être connecté pour accéder à cette section.
|
|
82
|
+
</p>
|
|
83
|
+
<button
|
|
84
|
+
onClick={signIn}
|
|
85
|
+
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
86
|
+
>
|
|
87
|
+
Se connecter
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Vérification d'autorisation personnalisée
|
|
95
|
+
if (authorize && !authorize(user)) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex items-center justify-center min-h-[400px] bg-gray-50">
|
|
98
|
+
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center">
|
|
99
|
+
<div className="text-red-500 text-5xl mb-4">🔒</div>
|
|
100
|
+
<h2 className="text-xl font-bold mb-4">Accès refusé</h2>
|
|
101
|
+
<p className="text-gray-600">{unauthorizedMessage}</p>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Autorisé
|
|
108
|
+
return <>{children}</>
|
|
109
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @orsetra/shared-auth
|
|
2
|
+
|
|
3
|
+
Shared authentication utilities for Orsetra platform using Zitadel OIDC.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @orsetra/shared-auth
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @orsetra/shared-auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Peer Dependencies
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install react react-dom next oidc-client-ts jose
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### 1. Configuration Zitadel
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { createAuthConfig } from '@orsetra/shared-auth/config'
|
|
25
|
+
|
|
26
|
+
const authConfig = createAuthConfig({
|
|
27
|
+
authority: process.env.NEXT_PUBLIC_ZITADEL_AUTHORITY,
|
|
28
|
+
client_id: process.env.NEXT_PUBLIC_ZITADEL_CLIENT_ID,
|
|
29
|
+
project_resource_id: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID,
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2. Provider dans l'App Principale
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { ZitadelProvider } from '@orsetra/shared-auth'
|
|
37
|
+
|
|
38
|
+
export default function RootLayout({ children }) {
|
|
39
|
+
return (
|
|
40
|
+
<html>
|
|
41
|
+
<body>
|
|
42
|
+
<ZitadelProvider config={authConfig}>
|
|
43
|
+
{children}
|
|
44
|
+
</ZitadelProvider>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Protected Routes
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { ProtectedRoute } from '@orsetra/shared-auth'
|
|
55
|
+
|
|
56
|
+
export default function DashboardPage() {
|
|
57
|
+
return (
|
|
58
|
+
<ProtectedRoute>
|
|
59
|
+
<Dashboard />
|
|
60
|
+
</ProtectedRoute>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 4. Utiliser le Service d'Auth
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { ZitadelAuthService } from '@orsetra/shared-auth/services'
|
|
69
|
+
|
|
70
|
+
const authService = new ZitadelAuthService(authConfig)
|
|
71
|
+
|
|
72
|
+
// Login
|
|
73
|
+
await authService.login()
|
|
74
|
+
|
|
75
|
+
// Logout
|
|
76
|
+
await authService.logout()
|
|
77
|
+
|
|
78
|
+
// Get user
|
|
79
|
+
const user = await authService.getUser()
|
|
80
|
+
|
|
81
|
+
// Get access token
|
|
82
|
+
const token = await authService.getAccessToken()
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Architecture Micro-Frontend
|
|
86
|
+
|
|
87
|
+
### App Main (Authentification Centralisée)
|
|
88
|
+
|
|
89
|
+
L'app `main` gère le flow OAuth :
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// apps/main/app/layout.tsx
|
|
93
|
+
import { ZitadelProvider } from '@orsetra/shared-auth'
|
|
94
|
+
|
|
95
|
+
export default function RootLayout({ children }) {
|
|
96
|
+
return (
|
|
97
|
+
<ZitadelProvider config={authConfig}>
|
|
98
|
+
{children}
|
|
99
|
+
</ZitadelProvider>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Micro-Apps (Validation de Token)
|
|
105
|
+
|
|
106
|
+
Les micro-apps valident le token reçu :
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// apps/assets/middleware.ts
|
|
110
|
+
import { verifyToken } from '@orsetra/shared-auth/utils'
|
|
111
|
+
|
|
112
|
+
export async function middleware(request: NextRequest) {
|
|
113
|
+
const token = request.headers.get('x-auth-token')
|
|
114
|
+
|
|
115
|
+
if (!token) {
|
|
116
|
+
return NextResponse.redirect('/login')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isValid = await verifyToken(token)
|
|
120
|
+
|
|
121
|
+
if (!isValid) {
|
|
122
|
+
return NextResponse.redirect('/login')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return NextResponse.next()
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Environment Variables
|
|
130
|
+
|
|
131
|
+
```env
|
|
132
|
+
NEXT_PUBLIC_ZITADEL_AUTHORITY=https://your-instance.zitadel.cloud
|
|
133
|
+
NEXT_PUBLIC_ZITADEL_CLIENT_ID=your-client-id
|
|
134
|
+
NEXT_PUBLIC_ZITADEL_PROJECT_ID=your-project-id
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
140
|
+
|
|
141
|
+
## Repository
|
|
142
|
+
|
|
143
|
+
[GitHub](https://github.com/orsetra/console-ui/tree/main/packages/shared-auth)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useEffect, useState, ReactNode } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { ZitadelAuthService, ZitadelConfig } from "./services/zitadel.auth.service"
|
|
6
|
+
import { AuthCard, AuthCardTitle, AuthCardDescription, AuthCardSpinner } from "./components/AuthCard"
|
|
7
|
+
import type { User } from "oidc-client-ts"
|
|
8
|
+
|
|
9
|
+
interface ZitadelContextType {
|
|
10
|
+
user: User | null
|
|
11
|
+
isLoading: boolean
|
|
12
|
+
isConfigured: boolean
|
|
13
|
+
login: () => Promise<void>
|
|
14
|
+
logout: () => Promise<void>
|
|
15
|
+
signIn: () => Promise<void>
|
|
16
|
+
signOut: () => Promise<void>
|
|
17
|
+
handleCallback: () => Promise<void>
|
|
18
|
+
refreshUser: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ZitadelContext = createContext<ZitadelContextType | undefined>(undefined)
|
|
22
|
+
|
|
23
|
+
interface ZitadelProviderProps {
|
|
24
|
+
children: ReactNode
|
|
25
|
+
config: ZitadelConfig
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ZitadelProvider({ children, config }: ZitadelProviderProps) {
|
|
29
|
+
const router = useRouter()
|
|
30
|
+
const [isConfigured, setIsConfigured] = useState(false)
|
|
31
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
32
|
+
const [user, setUser] = useState<User | null>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const configure = async () => {
|
|
36
|
+
try {
|
|
37
|
+
ZitadelAuthService.configureAuth(config)
|
|
38
|
+
setIsConfigured(true)
|
|
39
|
+
|
|
40
|
+
const currentUser = await ZitadelAuthService.getUser()
|
|
41
|
+
setUser(currentUser)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error("Failed to configure Zitadel auth:", error)
|
|
44
|
+
} finally {
|
|
45
|
+
setIsLoading(false)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
configure()
|
|
50
|
+
}, [config])
|
|
51
|
+
|
|
52
|
+
const signIn = async () => {
|
|
53
|
+
await ZitadelAuthService.signIn()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const signOut = async () => {
|
|
57
|
+
await ZitadelAuthService.signOut()
|
|
58
|
+
setUser(null)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const handleCallback = async () => {
|
|
62
|
+
try {
|
|
63
|
+
await ZitadelAuthService.handleCallback()
|
|
64
|
+
const currentUser = await ZitadelAuthService.getUser()
|
|
65
|
+
setUser(currentUser)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Callback handling failed:', error)
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const refreshUser = async () => {
|
|
73
|
+
const currentUser = await ZitadelAuthService.getUser()
|
|
74
|
+
setUser(currentUser)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Aliases pour compatibilité
|
|
78
|
+
const login = signIn
|
|
79
|
+
const logout = signOut
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!isLoading && !user && isConfigured) {
|
|
83
|
+
signIn()
|
|
84
|
+
}
|
|
85
|
+
}, [isLoading, user, isConfigured])
|
|
86
|
+
|
|
87
|
+
const value: ZitadelContextType = {
|
|
88
|
+
user,
|
|
89
|
+
isLoading,
|
|
90
|
+
isConfigured,
|
|
91
|
+
login,
|
|
92
|
+
logout,
|
|
93
|
+
signIn,
|
|
94
|
+
signOut,
|
|
95
|
+
handleCallback,
|
|
96
|
+
refreshUser,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (isLoading) {
|
|
100
|
+
return (
|
|
101
|
+
<ZitadelContext.Provider value={value}>
|
|
102
|
+
<AuthCard>
|
|
103
|
+
<div className="text-center py-8">
|
|
104
|
+
<AuthCardSpinner />
|
|
105
|
+
<AuthCardTitle>Chargement</AuthCardTitle>
|
|
106
|
+
<AuthCardDescription>Vérification de l'authentification...</AuthCardDescription>
|
|
107
|
+
</div>
|
|
108
|
+
</AuthCard>
|
|
109
|
+
</ZitadelContext.Provider>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!user) {
|
|
114
|
+
return (
|
|
115
|
+
<ZitadelContext.Provider value={value}>
|
|
116
|
+
<AuthCard>
|
|
117
|
+
<div className="text-center py-8">
|
|
118
|
+
<AuthCardSpinner />
|
|
119
|
+
<AuthCardTitle>Redirection</AuthCardTitle>
|
|
120
|
+
<AuthCardDescription>Veuillez patienter...</AuthCardDescription>
|
|
121
|
+
</div>
|
|
122
|
+
</AuthCard>
|
|
123
|
+
</ZitadelContext.Provider>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<ZitadelContext.Provider value={value}>
|
|
129
|
+
{children}
|
|
130
|
+
</ZitadelContext.Provider>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function useZitadel() {
|
|
135
|
+
const context = useContext(ZitadelContext)
|
|
136
|
+
if (context === undefined) {
|
|
137
|
+
throw new Error("useZitadel must be used within a ZitadelProvider")
|
|
138
|
+
}
|
|
139
|
+
return context
|
|
140
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useState } from "react"
|
|
4
|
+
|
|
5
|
+
interface AuthCardProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Composant Card pour les pages d'authentification
|
|
12
|
+
* Utilise le même style que le login Zitadel (IBM Carbon Design)
|
|
13
|
+
*/
|
|
14
|
+
export function AuthCard({ children, className = "" }: AuthCardProps) {
|
|
15
|
+
const [isDark, setIsDark] = useState(false)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
// Détecter le dark mode
|
|
19
|
+
const checkDarkMode = () => {
|
|
20
|
+
setIsDark(document.documentElement.classList.contains('dark'))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
checkDarkMode()
|
|
24
|
+
|
|
25
|
+
// Observer les changements de thème
|
|
26
|
+
const observer = new MutationObserver(checkDarkMode)
|
|
27
|
+
observer.observe(document.documentElement, {
|
|
28
|
+
attributes: true,
|
|
29
|
+
attributeFilter: ['class']
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
return () => observer.disconnect()
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const bgColor = isDark ? 'var(--theme-dark-background-600)' : 'var(--theme-light-background-600)'
|
|
36
|
+
const cardBg = isDark ? 'var(--theme-dark-background-500)' : 'var(--theme-light-background-100)'
|
|
37
|
+
const borderColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="relative flex min-h-screen flex-col justify-center" style={{ backgroundColor: bgColor }}>
|
|
41
|
+
<div className="relative mx-auto py-8 px-4" style={{ width: '440px' }}>
|
|
42
|
+
<div
|
|
43
|
+
className={`p-8 ${className}`}
|
|
44
|
+
style={{
|
|
45
|
+
backgroundColor: cardBg,
|
|
46
|
+
border: '1px solid',
|
|
47
|
+
borderColor: borderColor,
|
|
48
|
+
borderRadius: '0',
|
|
49
|
+
width: '100%',
|
|
50
|
+
height: '450px',
|
|
51
|
+
boxShadow: 'none',
|
|
52
|
+
display: 'flex',
|
|
53
|
+
flexDirection: 'column',
|
|
54
|
+
justifyContent: 'center',
|
|
55
|
+
overflow: 'hidden'
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AuthCardTitleProps {
|
|
66
|
+
children: ReactNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function AuthCardTitle({ children }: AuthCardTitleProps) {
|
|
70
|
+
const [isDark, setIsDark] = useState(false)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const checkDarkMode = () => setIsDark(document.documentElement.classList.contains('dark'))
|
|
74
|
+
checkDarkMode()
|
|
75
|
+
const observer = new MutationObserver(checkDarkMode)
|
|
76
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
77
|
+
return () => observer.disconnect()
|
|
78
|
+
}, [])
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<h2
|
|
82
|
+
className="text-xl font-semibold mb-3 text-center"
|
|
83
|
+
style={{ color: isDark ? 'var(--theme-dark-text)' : 'var(--theme-light-text)' }}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</h2>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface AuthCardDescriptionProps {
|
|
91
|
+
children: ReactNode
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function AuthCardDescription({ children }: AuthCardDescriptionProps) {
|
|
95
|
+
const [isDark, setIsDark] = useState(false)
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const checkDarkMode = () => setIsDark(document.documentElement.classList.contains('dark'))
|
|
99
|
+
checkDarkMode()
|
|
100
|
+
const observer = new MutationObserver(checkDarkMode)
|
|
101
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
102
|
+
return () => observer.disconnect()
|
|
103
|
+
}, [])
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<p
|
|
107
|
+
className="text-center leading-relaxed"
|
|
108
|
+
style={{ color: isDark ? 'var(--theme-dark-secondary-text)' : 'var(--theme-light-secondary-text)' }}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</p>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface AuthCardSpinnerProps {
|
|
116
|
+
className?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function AuthCardSpinner({ className = "" }: AuthCardSpinnerProps) {
|
|
120
|
+
const [isDark, setIsDark] = useState(false)
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const checkDarkMode = () => setIsDark(document.documentElement.classList.contains('dark'))
|
|
124
|
+
checkDarkMode()
|
|
125
|
+
const observer = new MutationObserver(checkDarkMode)
|
|
126
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
127
|
+
return () => observer.disconnect()
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
const spinnerColor = isDark ? 'var(--theme-dark-primary-500)' : 'var(--theme-light-primary-500)'
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
className={`animate-spin rounded-full h-16 w-16 mx-auto mb-6 ${className}`}
|
|
135
|
+
style={{
|
|
136
|
+
borderWidth: '4px',
|
|
137
|
+
borderStyle: 'solid',
|
|
138
|
+
borderColor: 'transparent',
|
|
139
|
+
borderBottomColor: spinnerColor
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { UserManagerSettings } from 'oidc-client-ts';
|
|
2
|
+
|
|
3
|
+
export type ZitadelConfig = Partial<UserManagerSettings> & {
|
|
4
|
+
project_resource_id?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Obtient l'URL de base de l'application selon l'environnement
|
|
9
|
+
* - Production: URL Vercel ou domaine personnalisé
|
|
10
|
+
* - Development: localhost:3000
|
|
11
|
+
*/
|
|
12
|
+
function getBaseUrl(): string {
|
|
13
|
+
// En production, utiliser l'URL de l'application
|
|
14
|
+
if (typeof window !== 'undefined') {
|
|
15
|
+
return window.location.origin;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Crée la configuration UserManager à partir de la config Zitadel
|
|
23
|
+
*/
|
|
24
|
+
export function createAuthConfig(zitadelConfig: ZitadelConfig): UserManagerSettings {
|
|
25
|
+
const baseUrl = getBaseUrl();
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
authority: `${zitadelConfig.authority}`,
|
|
29
|
+
client_id: `${zitadelConfig.client_id}`,
|
|
30
|
+
redirect_uri: zitadelConfig.redirect_uri ?? `${baseUrl}/callback`,
|
|
31
|
+
response_type: zitadelConfig.response_type ?? 'code',
|
|
32
|
+
scope: zitadelConfig.scope ?? `openid profile email ${
|
|
33
|
+
zitadelConfig.project_resource_id
|
|
34
|
+
? `urn:zitadel:iam:org:project:id:${zitadelConfig.project_resource_id}:aud urn:zitadel:iam:org:projects:roles`
|
|
35
|
+
: ''
|
|
36
|
+
}`,
|
|
37
|
+
prompt: zitadelConfig.prompt ?? '',
|
|
38
|
+
post_logout_redirect_uri: zitadelConfig.post_logout_redirect_uri ?? baseUrl,
|
|
39
|
+
response_mode: zitadelConfig.response_mode ?? 'query',
|
|
40
|
+
disablePKCE: zitadelConfig.disablePKCE,
|
|
41
|
+
extraQueryParams: zitadelConfig.extraQueryParams,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Auth exports
|
|
2
|
+
export { ZitadelProvider, useZitadel } from './ZitadelProvider'
|
|
3
|
+
export { ProtectedRoute } from './ProtectedRoute'
|
|
4
|
+
export { ZitadelAuthService } from './services/zitadel.auth.service'
|
|
5
|
+
export { createAuthConfig } from './config/zitadel.config'
|
|
6
|
+
export type { ZitadelConfig } from './config/zitadel.config'
|
|
7
|
+
export * from './utils'
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orsetra/shared-auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared authentication utilities for Orsetra platform using Zitadel",
|
|
5
|
+
"main": "./index.ts",
|
|
6
|
+
"types": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts",
|
|
9
|
+
"./config": "./config/zitadel.config.ts",
|
|
10
|
+
"./services": "./services/zitadel.auth.service.ts",
|
|
11
|
+
"./components": "./components/index.ts",
|
|
12
|
+
"./utils": "./utils/redirect-utils.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"ZitadelProvider.tsx",
|
|
17
|
+
"ProtectedRoute.tsx",
|
|
18
|
+
"config",
|
|
19
|
+
"services",
|
|
20
|
+
"components",
|
|
21
|
+
"utils",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/orsetra/console-ui.git",
|
|
30
|
+
"directory": "packages/shared-auth"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"authentication",
|
|
34
|
+
"zitadel",
|
|
35
|
+
"oidc",
|
|
36
|
+
"oauth2",
|
|
37
|
+
"orsetra"
|
|
38
|
+
],
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
41
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
42
|
+
"next": "^14.0.0 || ^15.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"oidc-client-ts": "^3.2.1",
|
|
46
|
+
"jose": "^5.9.6"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/react": "^19",
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { User, UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
|
4
|
+
import { ZitadelConfig, createAuthConfig } from '../config/zitadel.config';
|
|
5
|
+
|
|
6
|
+
export type { ZitadelConfig };
|
|
7
|
+
|
|
8
|
+
export interface ZitadelAuth {
|
|
9
|
+
authorize(): Promise<void>;
|
|
10
|
+
signout(): Promise<void>;
|
|
11
|
+
userManager: UserManager;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createZitadelAuth(zitadelConfig: ZitadelConfig): ZitadelAuth {
|
|
15
|
+
const authConfig = createAuthConfig(zitadelConfig);
|
|
16
|
+
|
|
17
|
+
const userManager = new UserManager({
|
|
18
|
+
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
|
19
|
+
loadUserInfo: true,
|
|
20
|
+
...authConfig,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
authorize: () => userManager.signinRedirect(),
|
|
25
|
+
signout: () => userManager.signoutRedirect(),
|
|
26
|
+
userManager,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ZitadelAuthService {
|
|
31
|
+
private static zitadelAuth: ZitadelAuth | null = null;
|
|
32
|
+
|
|
33
|
+
static configureAuth(config: ZitadelConfig) {
|
|
34
|
+
this.zitadelAuth = createZitadelAuth(config);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private static getAuth(): ZitadelAuth {
|
|
38
|
+
if (!this.zitadelAuth) {
|
|
39
|
+
throw new Error('Zitadel auth not configured. Call configureAuth() first.');
|
|
40
|
+
}
|
|
41
|
+
return this.zitadelAuth;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static async signIn() {
|
|
45
|
+
try {
|
|
46
|
+
await this.getAuth().authorize();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw this.handleAuthError(error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static async signOut() {
|
|
53
|
+
try {
|
|
54
|
+
await this.getAuth().signout();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw this.handleAuthError(error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static async getUser(): Promise<User | null> {
|
|
61
|
+
try {
|
|
62
|
+
return await this.getAuth().userManager.getUser();
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('Error getting user:', error);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static async handleCallback(): Promise<User> {
|
|
70
|
+
try {
|
|
71
|
+
return await this.getAuth().userManager.signinRedirectCallback();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw this.handleAuthError(error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async isAuthenticated(): Promise<boolean> {
|
|
78
|
+
const user = await this.getUser();
|
|
79
|
+
return user !== null && !user.expired;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static getUserManager(): UserManager {
|
|
83
|
+
return this.getAuth().userManager;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static handleAuthError(error: any): Error {
|
|
87
|
+
console.error('Auth error:', error);
|
|
88
|
+
if (error.message) {
|
|
89
|
+
return new Error(error.message);
|
|
90
|
+
}
|
|
91
|
+
return new Error('An error occurred during authentication');
|
|
92
|
+
}
|
|
93
|
+
}
|
package/utils/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jwtVerify, createRemoteJWKSet } from 'jose'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Vérifie et décode un token JWT Zitadel
|
|
5
|
+
*/
|
|
6
|
+
export async function verifyToken(
|
|
7
|
+
token: string,
|
|
8
|
+
authority: string,
|
|
9
|
+
clientId: string
|
|
10
|
+
): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
const JWKS = createRemoteJWKSet(new URL(`${authority}/.well-known/openid-configuration/jwks`))
|
|
13
|
+
|
|
14
|
+
const { payload } = await jwtVerify(token, JWKS, {
|
|
15
|
+
issuer: authority,
|
|
16
|
+
audience: clientId,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// Vérifier l'expiration
|
|
20
|
+
if (payload.exp && payload.exp < Date.now() / 1000) {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return true
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Token validation failed:', error)
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Décode un token JWT sans vérification (pour debug uniquement)
|
|
33
|
+
*/
|
|
34
|
+
export function decodeToken(token: string): any {
|
|
35
|
+
try {
|
|
36
|
+
const [, payload] = token.split('.')
|
|
37
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString())
|
|
38
|
+
} catch {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extrait le token du header Authorization
|
|
45
|
+
*/
|
|
46
|
+
export function extractTokenFromHeader(authHeader: string | null): string | null {
|
|
47
|
+
if (!authHeader) return null
|
|
48
|
+
|
|
49
|
+
const [type, token] = authHeader.split(' ')
|
|
50
|
+
|
|
51
|
+
if (type !== 'Bearer') return null
|
|
52
|
+
|
|
53
|
+
return token
|
|
54
|
+
}
|