Update var
This commit is contained in:
parent
f56ca4e982
commit
d2edd23c7a
4
.env
4
.env
@ -100,7 +100,7 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=mySecretPassword
|
REDIS_PASSWORD=mySecretPassword
|
||||||
|
|
||||||
MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
|
MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
|
||||||
MICROSOFT_CLIENT_SECRET="eIx8Q~N3ZnXTjTsVM3ECZio4G7t.BO6AYlD1-b2h"
|
MICROSOFT_CLIENT_SECRET="GOO8Q~.~zJEz5xTSH4OnNgKe.DCuqr~IB~Gb~c0O"
|
||||||
MICROSOFT_REDIRECT_URI="https://lab.slm-lab.net/ms"
|
MICROSOFT_REDIRECT_URI="https://hub.slm-lab.net/ms"
|
||||||
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
|
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
|
||||||
N8N_WEBHOOK_URL="https://brain.slm-lab.net/webhook-test/mission-created"
|
N8N_WEBHOOK_URL="https://brain.slm-lab.net/webhook-test/mission-created"
|
||||||
|
|||||||
224
AUTHENTICATION_FIXES.md
Normal file
224
AUTHENTICATION_FIXES.md
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
# Authentication Flow Fixes
|
||||||
|
|
||||||
|
## Issues Fixed
|
||||||
|
|
||||||
|
### 1. Logout Loop Issue ✅
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- User couldn't log out - infinite redirect loop
|
||||||
|
- Sign-in page auto-triggered Keycloak login even when user was already authenticated
|
||||||
|
- Keycloak session cookies weren't cleared, causing immediate re-authentication
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
- `/signin` page had `useEffect(() => { signIn("keycloak") }, [])` that always triggered login
|
||||||
|
- No check for existing authentication status
|
||||||
|
- Keycloak logout endpoint was never called, leaving Keycloak cookies valid
|
||||||
|
|
||||||
|
**Fix Applied**:
|
||||||
|
1. **Sign-in page** (`app/signin/page.tsx`):
|
||||||
|
- Added check for existing session before triggering login
|
||||||
|
- If user is already authenticated, redirect to home
|
||||||
|
- Only trigger Keycloak login if status is "unauthenticated"
|
||||||
|
|
||||||
|
2. **Sign-out handler** (`components/auth/signout-handler.tsx`):
|
||||||
|
- Now properly calls Keycloak logout endpoint
|
||||||
|
- Uses ID token for proper logout
|
||||||
|
- Clears both NextAuth and Keycloak cookies
|
||||||
|
|
||||||
|
3. **Main navigation logout** (`components/main-nav.tsx`):
|
||||||
|
- Fixed to use `idToken` instead of `accessToken` for Keycloak logout
|
||||||
|
- Proper logout flow with Keycloak endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Iframe Applications Logging Out ✅
|
||||||
|
|
||||||
|
**Problem**:
|
||||||
|
- Iframe applications were logging out even when user was still authenticated in dashboard
|
||||||
|
- Desynchronization between NextAuth session and Keycloak session
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
- Sign-out only cleared NextAuth cookies
|
||||||
|
- Keycloak session cookies remained valid but could expire independently
|
||||||
|
- Iframe apps rely on Keycloak cookies for SSO
|
||||||
|
- When Keycloak cookies expired/invalidated, iframes logged out but dashboard stayed logged in
|
||||||
|
|
||||||
|
**Fix Applied**:
|
||||||
|
1. **ID Token Storage** (`app/api/auth/options.ts`):
|
||||||
|
- Now stores `idToken` from Keycloak in JWT
|
||||||
|
- Exposes `idToken` in session object
|
||||||
|
- Preserves ID token during token refresh
|
||||||
|
|
||||||
|
2. **Proper Keycloak Logout**:
|
||||||
|
- Sign-out now calls Keycloak logout endpoint with `id_token_hint`
|
||||||
|
- This properly invalidates Keycloak session and clears Keycloak cookies
|
||||||
|
- Ensures synchronization between dashboard and iframe apps
|
||||||
|
|
||||||
|
3. **Type Definitions** (`types/next-auth.d.ts`):
|
||||||
|
- Added `idToken` to Session and JWT interfaces
|
||||||
|
- Type-safe access to ID token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **`app/api/auth/options.ts`**
|
||||||
|
- Added `idToken` to JWT interface
|
||||||
|
- Store `account.id_token` in JWT during initial authentication
|
||||||
|
- Expose `idToken` in session callback
|
||||||
|
- Preserve `idToken` during token refresh
|
||||||
|
|
||||||
|
2. **`app/signin/page.tsx`**
|
||||||
|
- Added session status check
|
||||||
|
- Prevent auto-login if already authenticated
|
||||||
|
- Redirect authenticated users to home
|
||||||
|
|
||||||
|
3. **`components/auth/signout-handler.tsx`**
|
||||||
|
- Call Keycloak logout endpoint with ID token
|
||||||
|
- Proper logout flow that clears both NextAuth and Keycloak sessions
|
||||||
|
|
||||||
|
4. **`components/main-nav.tsx`**
|
||||||
|
- Fixed logout button to use `idToken` instead of `accessToken`
|
||||||
|
- Proper Keycloak logout flow
|
||||||
|
|
||||||
|
5. **`types/next-auth.d.ts`**
|
||||||
|
- Added `idToken?: string` to Session interface
|
||||||
|
- Added `idToken?: string` to JWT interface (both modules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works Now
|
||||||
|
|
||||||
|
### Sign-In Flow (Fixed)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User navigates to /signin
|
||||||
|
2. Check session status:
|
||||||
|
- If authenticated → Redirect to /
|
||||||
|
- If unauthenticated → Trigger Keycloak login
|
||||||
|
3. After Keycloak authentication:
|
||||||
|
- Store tokens (access, refresh, ID token)
|
||||||
|
- Initialize storage
|
||||||
|
- Redirect to dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sign-Out Flow (Fixed)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks logout
|
||||||
|
2. Sign out from NextAuth (clears NextAuth cookies)
|
||||||
|
3. Call Keycloak logout endpoint:
|
||||||
|
- URL: ${KEYCLOAK_ISSUER}/protocol/openid-connect/logout
|
||||||
|
- Parameters:
|
||||||
|
* post_logout_redirect_uri: /signin
|
||||||
|
* id_token_hint: <ID token from session>
|
||||||
|
4. Keycloak clears its session and cookies
|
||||||
|
5. Redirect to /signin (no auto-login loop)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iframe SSO (Fixed)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User authenticates in dashboard
|
||||||
|
2. Keycloak sets session cookies
|
||||||
|
3. Iframe apps read Keycloak cookies
|
||||||
|
4. When user logs out:
|
||||||
|
- Keycloak logout endpoint is called
|
||||||
|
- Keycloak cookies are cleared
|
||||||
|
- Iframe apps lose access (synchronized logout)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables Required
|
||||||
|
|
||||||
|
Ensure these are set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for logout
|
||||||
|
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah
|
||||||
|
|
||||||
|
# Already required for authentication
|
||||||
|
KEYCLOAK_CLIENT_ID=neah-dashboard
|
||||||
|
KEYCLOAK_CLIENT_SECRET=<secret>
|
||||||
|
KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah
|
||||||
|
NEXTAUTH_URL=https://dashboard.example.com
|
||||||
|
NEXTAUTH_SECRET=<secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: `NEXT_PUBLIC_KEYCLOAK_ISSUER` must be set for client-side logout to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Logout Flow
|
||||||
|
- [ ] Click logout button
|
||||||
|
- [ ] Should redirect to Keycloak logout
|
||||||
|
- [ ] Should redirect back to /signin
|
||||||
|
- [ ] Should NOT auto-login (no loop)
|
||||||
|
- [ ] Should be able to manually log in again
|
||||||
|
|
||||||
|
### Sign-In Flow
|
||||||
|
- [ ] Navigate to /signin when not authenticated
|
||||||
|
- [ ] Should trigger Keycloak login
|
||||||
|
- [ ] Navigate to /signin when already authenticated
|
||||||
|
- [ ] Should redirect to / (no auto-login trigger)
|
||||||
|
|
||||||
|
### Iframe SSO
|
||||||
|
- [ ] Log in to dashboard
|
||||||
|
- [ ] Open iframe application
|
||||||
|
- [ ] Should be automatically authenticated
|
||||||
|
- [ ] Log out from dashboard
|
||||||
|
- [ ] Iframe application should also lose authentication
|
||||||
|
- [ ] Refresh iframe - should require login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Notes
|
||||||
|
|
||||||
|
### ID Token vs Access Token
|
||||||
|
|
||||||
|
- **Access Token**: Used for API calls to Keycloak-protected resources
|
||||||
|
- **ID Token**: Used for user identification and logout
|
||||||
|
- **Refresh Token**: Used to get new access tokens
|
||||||
|
|
||||||
|
The ID token is required for proper Keycloak logout. It tells Keycloak which session to invalidate.
|
||||||
|
|
||||||
|
### Cookie Synchronization
|
||||||
|
|
||||||
|
The fix ensures that:
|
||||||
|
1. NextAuth cookies are cleared (dashboard logout)
|
||||||
|
2. Keycloak cookies are cleared (via logout endpoint)
|
||||||
|
3. Both happen in sequence, maintaining synchronization
|
||||||
|
|
||||||
|
### Token Refresh
|
||||||
|
|
||||||
|
During token refresh, the ID token is preserved (Keycloak doesn't issue new ID tokens on refresh). This ensures logout continues to work even after token refreshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### If logout still loops:
|
||||||
|
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify `NEXT_PUBLIC_KEYCLOAK_ISSUER` is set correctly
|
||||||
|
3. Check that Keycloak logout endpoint is accessible
|
||||||
|
4. Verify ID token is present in session: `console.log(session?.idToken)`
|
||||||
|
|
||||||
|
### If iframes still log out independently:
|
||||||
|
|
||||||
|
1. Check Keycloak cookie domain configuration
|
||||||
|
2. Verify iframe apps are configured to use same Keycloak realm
|
||||||
|
3. Check browser cookie settings (third-party cookies may be blocked)
|
||||||
|
4. Verify Keycloak session timeout settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Date**: 2024
|
||||||
|
**Status**: ✅ Fixed
|
||||||
|
**Version**: 1.0
|
||||||
|
|
||||||
988
AUTHENTICATION_FLOW_AUDIT.md
Normal file
988
AUTHENTICATION_FLOW_AUDIT.md
Normal file
@ -0,0 +1,988 @@
|
|||||||
|
# Authentication Flow Audit - NextAuth with Keycloak & SSO for Iframe Applications
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document provides a comprehensive audit of the authentication architecture in the Neah dashboard application. The system uses **NextAuth.js v4** with **Keycloak** as the OAuth provider, implementing JWT-based sessions and supporting Single Sign-On (SSO) for multiple iframe-embedded applications via cookie-based authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Components
|
||||||
|
1. **NextAuth.js** - Authentication framework
|
||||||
|
2. **Keycloak** - Identity Provider (IdP) via OAuth 2.0/OpenID Connect
|
||||||
|
3. **JWT Strategy** - Session management (no database sessions)
|
||||||
|
4. **Iframe Applications** - Multiple embedded applications using SSO via cookies
|
||||||
|
5. **Keycloak Admin Client** - Server-side user management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Authentication Entry Points
|
||||||
|
|
||||||
|
### 1.1 Sign-In Page (`/app/signin/page.tsx`)
|
||||||
|
|
||||||
|
**Location**: `app/signin/page.tsx`
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
```typescript
|
||||||
|
1. User navigates to /signin
|
||||||
|
2. Component automatically triggers: signIn("keycloak", { callbackUrl: "/" })
|
||||||
|
3. Redirects to Keycloak authorization endpoint
|
||||||
|
4. After Keycloak authentication, initializes storage via /api/storage/init
|
||||||
|
5. Redirects to home page
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods**:
|
||||||
|
- `signIn("keycloak")` - NextAuth client-side method
|
||||||
|
- Automatic redirect to Keycloak OAuth flow
|
||||||
|
- Storage initialization after successful authentication
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
- `next-auth/react` - Client-side NextAuth hooks
|
||||||
|
- Storage API endpoint for user space initialization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. NextAuth Configuration
|
||||||
|
|
||||||
|
### 2.1 Route Handler (`/app/api/auth/[...nextauth]/route.ts`)
|
||||||
|
|
||||||
|
**Location**: `app/api/auth/[...nextauth]/route.ts`
|
||||||
|
|
||||||
|
**Purpose**: NextAuth API route handler for all authentication endpoints
|
||||||
|
|
||||||
|
**Endpoints Handled**:
|
||||||
|
- `GET/POST /api/auth/signin` - Sign in
|
||||||
|
- `GET/POST /api/auth/signout` - Sign out
|
||||||
|
- `GET /api/auth/session` - Get current session
|
||||||
|
- `GET /api/auth/csrf` - CSRF token
|
||||||
|
- `GET /api/auth/providers` - Available providers
|
||||||
|
- `GET /api/auth/callback/keycloak` - OAuth callback
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "../options";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Auth Options Configuration (`/app/api/auth/options.ts`)
|
||||||
|
|
||||||
|
**Location**: `app/api/auth/options.ts`
|
||||||
|
|
||||||
|
**This is the core authentication configuration file.**
|
||||||
|
|
||||||
|
#### 2.2.1 Keycloak Provider Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
KeycloakProvider({
|
||||||
|
clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"),
|
||||||
|
clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"),
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: "openid profile email roles" // Requested OAuth scopes
|
||||||
|
}
|
||||||
|
},
|
||||||
|
profile(profile) { /* Profile transformation */ }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Environment Variables Required**:
|
||||||
|
- `KEYCLOAK_CLIENT_ID` - OAuth client identifier
|
||||||
|
- `KEYCLOAK_CLIENT_SECRET` - OAuth client secret
|
||||||
|
- `KEYCLOAK_ISSUER` - Keycloak realm issuer URL (e.g., `https://keycloak.example.com/realms/neah`)
|
||||||
|
|
||||||
|
**OAuth Scopes Requested**:
|
||||||
|
- `openid` - OpenID Connect core
|
||||||
|
- `profile` - User profile information
|
||||||
|
- `email` - User email address
|
||||||
|
- `roles` - User roles from Keycloak realm
|
||||||
|
|
||||||
|
#### 2.2.2 Profile Callback
|
||||||
|
|
||||||
|
**Location**: Lines 109-137 in `options.ts`
|
||||||
|
|
||||||
|
**Purpose**: Transforms Keycloak user profile into NextAuth user object
|
||||||
|
|
||||||
|
**Process**:
|
||||||
|
1. Receives Keycloak profile with `realm_access.roles`
|
||||||
|
2. Extracts roles from `realm_access.roles` array
|
||||||
|
3. Cleans roles by:
|
||||||
|
- Removing `ROLE_` prefix (if present)
|
||||||
|
- Converting to lowercase
|
||||||
|
4. Maps Keycloak profile fields to NextAuth user:
|
||||||
|
- `sub` → `id`
|
||||||
|
- `name` or `preferred_username` → `name`
|
||||||
|
- `email` → `email`
|
||||||
|
- `given_name` → `first_name`
|
||||||
|
- `family_name` → `last_name`
|
||||||
|
- `preferred_username` → `username`
|
||||||
|
- Cleaned roles → `role[]`
|
||||||
|
|
||||||
|
**Code Flow**:
|
||||||
|
```typescript
|
||||||
|
profile(profile) {
|
||||||
|
const roles = profile.realm_access?.roles || [];
|
||||||
|
const cleanRoles = roles.map((role: string) =>
|
||||||
|
role.replace(/^ROLE_/, '').toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
name: profile.name ?? profile.preferred_username,
|
||||||
|
email: profile.email,
|
||||||
|
first_name: profile.given_name ?? '',
|
||||||
|
last_name: profile.family_name ?? '',
|
||||||
|
username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '',
|
||||||
|
role: cleanRoles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.3 Session Configuration
|
||||||
|
|
||||||
|
**Location**: Lines 140-143
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
session: {
|
||||||
|
strategy: "jwt", // JWT-based sessions (no database)
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics**:
|
||||||
|
- **Strategy**: JWT (stateless, no database lookups)
|
||||||
|
- **Max Age**: 30 days (2,592,000 seconds)
|
||||||
|
- **Storage**: Encrypted JWT stored in HTTP-only cookies
|
||||||
|
|
||||||
|
#### 2.2.4 JWT Callback
|
||||||
|
|
||||||
|
**Location**: Lines 145-181
|
||||||
|
|
||||||
|
**Purpose**: Handles JWT token creation and refresh
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
|
||||||
|
**Initial Authentication (account & profile present)**:
|
||||||
|
```typescript
|
||||||
|
if (account && profile) {
|
||||||
|
1. Extract roles from Keycloak profile
|
||||||
|
2. Clean roles (remove ROLE_ prefix, lowercase)
|
||||||
|
3. Store in JWT token:
|
||||||
|
- accessToken: account.access_token (Keycloak access token)
|
||||||
|
- refreshToken: account.refresh_token (Keycloak refresh token)
|
||||||
|
- accessTokenExpires: account.expires_at (expiration timestamp)
|
||||||
|
- sub: Keycloak user ID
|
||||||
|
- role: cleaned roles array
|
||||||
|
- username, first_name, last_name: from profile
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Subsequent Requests (token refresh check)**:
|
||||||
|
```typescript
|
||||||
|
else if (token.accessToken) {
|
||||||
|
1. Decode JWT to extract roles (if not already in token)
|
||||||
|
2. Check if token is expired:
|
||||||
|
- If expired: Call refreshAccessToken()
|
||||||
|
- If valid: Return existing token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Expiration Check**:
|
||||||
|
```typescript
|
||||||
|
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
|
||||||
|
return token; // Token still valid
|
||||||
|
}
|
||||||
|
return refreshAccessToken(token); // Token expired, refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: There's a **BUG** in line 176 - it multiplies `accessTokenExpires` by 1000, but `expires_at` from Keycloak is already in seconds since epoch. This should be checked.
|
||||||
|
|
||||||
|
#### 2.2.5 Token Refresh Function
|
||||||
|
|
||||||
|
**Location**: Lines 64-96
|
||||||
|
|
||||||
|
**Purpose**: Refreshes expired Keycloak access tokens
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
async function refreshAccessToken(token: JWT) {
|
||||||
|
1. POST to Keycloak token endpoint:
|
||||||
|
- URL: ${KEYCLOAK_ISSUER}/protocol/openid-connect/token
|
||||||
|
- Method: POST
|
||||||
|
- Body:
|
||||||
|
* client_id: KEYCLOAK_CLIENT_ID
|
||||||
|
* client_secret: KEYCLOAK_CLIENT_SECRET
|
||||||
|
* grant_type: refresh_token
|
||||||
|
* refresh_token: token.refreshToken
|
||||||
|
|
||||||
|
2. On Success:
|
||||||
|
- Update accessToken
|
||||||
|
- Update refreshToken (if new one provided)
|
||||||
|
- Update accessTokenExpires: Date.now() + expires_in * 1000
|
||||||
|
|
||||||
|
3. On Error:
|
||||||
|
- Set token.error = "RefreshAccessTokenError"
|
||||||
|
- Return token with error flag
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Handling**: Sets `token.error` flag which is checked in session callback
|
||||||
|
|
||||||
|
#### 2.2.6 Session Callback
|
||||||
|
|
||||||
|
**Location**: Lines 182-202
|
||||||
|
|
||||||
|
**Purpose**: Transforms JWT token into session object for client-side use
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
```typescript
|
||||||
|
async session({ session, token }) {
|
||||||
|
1. Check for refresh errors:
|
||||||
|
if (token.error) throw new Error(token.error)
|
||||||
|
|
||||||
|
2. Build session.user object:
|
||||||
|
- id: token.sub (Keycloak user ID)
|
||||||
|
- email: token.email
|
||||||
|
- name: token.name
|
||||||
|
- image: null
|
||||||
|
- username: token.username
|
||||||
|
- first_name: token.first_name
|
||||||
|
- last_name: token.last_name
|
||||||
|
- role: token.role (array)
|
||||||
|
- nextcloudInitialized: false (default)
|
||||||
|
|
||||||
|
3. Add accessToken to session:
|
||||||
|
session.accessToken = token.accessToken
|
||||||
|
|
||||||
|
4. Return session
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: The `accessToken` (Keycloak OAuth token) is exposed in the session object, making it available client-side via `useSession()` hook.
|
||||||
|
|
||||||
|
#### 2.2.7 Custom Pages
|
||||||
|
|
||||||
|
**Location**: Lines 204-207
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
pages: {
|
||||||
|
signIn: '/signin',
|
||||||
|
error: '/signin',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom Routes**:
|
||||||
|
- Sign-in page: `/signin` (instead of default `/api/auth/signin`)
|
||||||
|
- Error page: `/signin` (redirects to sign-in on errors)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Authentication Flow Step-by-Step
|
||||||
|
|
||||||
|
### 3.1 Initial Sign-In Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Browser │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
│ 1. GET /signin
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ /app/signin/page.tsx │
|
||||||
|
│ - Auto-triggers │
|
||||||
|
│ signIn("keycloak") │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 2. Redirect to NextAuth
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ /api/auth/signin/keycloak │
|
||||||
|
│ - Generates OAuth state │
|
||||||
|
│ - Redirects to Keycloak │
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ 3. GET /realms/{realm}/protocol/openid-connect/auth
|
||||||
|
│ ?client_id=...
|
||||||
|
│ &redirect_uri=...
|
||||||
|
│ &response_type=code
|
||||||
|
│ &scope=openid profile email roles
|
||||||
|
│ &state=...
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Keycloak Server │
|
||||||
|
│ - Login page │
|
||||||
|
│ - User credentials │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 4. User authenticates
|
||||||
|
│
|
||||||
|
│ 5. POST /realms/{realm}/protocol/openid-connect/token
|
||||||
|
│ (Authorization code exchange)
|
||||||
|
│
|
||||||
|
│ 6. Keycloak returns:
|
||||||
|
│ - access_token
|
||||||
|
│ - refresh_token
|
||||||
|
│ - id_token
|
||||||
|
│ - expires_in
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ /api/auth/callback/keycloak │
|
||||||
|
│ - Receives authorization code│
|
||||||
|
│ - Exchanges for tokens │
|
||||||
|
│ - Fetches user profile │
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ 7. JWT Callback
|
||||||
|
│ - Stores tokens in JWT
|
||||||
|
│ - Extracts user info
|
||||||
|
│ - Cleans roles
|
||||||
|
│
|
||||||
|
│ 8. Session Callback
|
||||||
|
│ - Builds session object
|
||||||
|
│
|
||||||
|
│ 9. Sets NextAuth cookies:
|
||||||
|
│ - next-auth.session-token (encrypted JWT)
|
||||||
|
│ - next-auth.csrf-token
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Browser (Client) │
|
||||||
|
│ - Cookies set │
|
||||||
|
│ - Redirect to / │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 10. GET / (home page)
|
||||||
|
│ - getServerSession() validates JWT
|
||||||
|
│ - Session available
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Dashboard Loaded │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Subsequent Request Flow (Authenticated)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ Browser │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
│ 1. GET /any-page
|
||||||
|
│ Cookie: next-auth.session-token=...
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Next.js Server │
|
||||||
|
│ getServerSession(authOptions)│
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
│ 2. Decrypt JWT from cookie
|
||||||
|
│
|
||||||
|
│ 3. Check token expiration
|
||||||
|
│
|
||||||
|
│ 4a. If valid:
|
||||||
|
│ - Extract user info
|
||||||
|
│ - Return session
|
||||||
|
│
|
||||||
|
│ 4b. If expired:
|
||||||
|
│ - Call refreshAccessToken()
|
||||||
|
│ - POST to Keycloak /token
|
||||||
|
│ - Update JWT with new tokens
|
||||||
|
│ - Return session
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Page Component │
|
||||||
|
│ - session available│
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Token Refresh Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ JWT Callback │
|
||||||
|
│ (Token expired) │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 1. Call refreshAccessToken()
|
||||||
|
│
|
||||||
|
│ 2. POST ${KEYCLOAK_ISSUER}/protocol/openid-connect/token
|
||||||
|
│ Body:
|
||||||
|
│ - client_id
|
||||||
|
│ - client_secret
|
||||||
|
│ - grant_type: refresh_token
|
||||||
|
│ - refresh_token: <current_refresh_token>
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Keycloak Server │
|
||||||
|
│ - Validates refresh│
|
||||||
|
│ token │
|
||||||
|
│ - Issues new tokens│
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 3. Returns:
|
||||||
|
│ - access_token (new)
|
||||||
|
│ - refresh_token (new, optional)
|
||||||
|
│ - expires_in
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Update JWT Token │
|
||||||
|
│ - New accessToken │
|
||||||
|
│ - New refreshToken │
|
||||||
|
│ - New expires time │
|
||||||
|
└──────┬──────────────┘
|
||||||
|
│
|
||||||
|
│ 4. Return updated token
|
||||||
|
│
|
||||||
|
│ 5. Session callback builds session
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Session Available │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Iframe SSO Architecture
|
||||||
|
|
||||||
|
### 4.1 Overview
|
||||||
|
|
||||||
|
The dashboard embeds multiple applications in iframes. These applications rely on **cookie-based SSO** to authenticate users automatically using the Keycloak session established in the parent dashboard.
|
||||||
|
|
||||||
|
### 4.2 Iframe Application Pages
|
||||||
|
|
||||||
|
**Pattern**: All iframe pages follow the same structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example: app/parole/page.tsx
|
||||||
|
export default async function Page() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/signin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveIframe
|
||||||
|
src={process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Iframe Applications Identified**:
|
||||||
|
1. **Parole** (`/parole`) - `NEXT_PUBLIC_IFRAME_PAROLE_URL`
|
||||||
|
2. **Agilite** (`/agilite`) - `NEXT_PUBLIC_IFRAME_AGILITY_URL`
|
||||||
|
3. **Alma** (`/alma`) - `NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL`
|
||||||
|
4. **Vision** (`/vision`) - `NEXT_PUBLIC_IFRAME_CONFERENCE_URL`
|
||||||
|
5. **The Message** (`/the-message`) - `NEXT_PUBLIC_IFRAME_THEMESSAGE_URL`
|
||||||
|
6. **WP Admin** (`/wp-admin`) - `NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL`
|
||||||
|
7. **Mediation** (`/mediation`) - `NEXT_PUBLIC_IFRAME_MEDIATIONS_URL`
|
||||||
|
8. **Apprendre** (`/apprendre`) - `NEXT_PUBLIC_IFRAME_LEARN_URL`
|
||||||
|
9. **Gite** (`/gite`) - `NEXT_PUBLIC_IFRAME_GITE_URL`
|
||||||
|
10. **Artlab** (`/artlab`) - `NEXT_PUBLIC_IFRAME_ARTLAB_URL`
|
||||||
|
11. **Calcul** (`/calcul`) - `NEXT_PUBLIC_IFRAME_CALCULATION_URL`
|
||||||
|
12. **Chapitre** (`/chapitre`) - `NEXT_PUBLIC_IFRAME_CHAPTER_URL`
|
||||||
|
13. **Dossiers** (`/dossiers`) - `NEXT_PUBLIC_IFRAME_DRIVE_URL`
|
||||||
|
14. **CRM** (`/crm`) - `NEXT_PUBLIC_IFRAME_MEDIATIONS_URL`
|
||||||
|
15. **Livres** (`/livres`) - `NEXT_PUBLIC_IFRAME_LIVRE_URL`
|
||||||
|
16. **Showcase** (`/showcase`) - `NEXT_PUBLIC_IFRAME_SHOWCASE_URL`
|
||||||
|
17. **Radio** (`/radio`) - `NEXT_PUBLIC_IFRAME_RADIO_URL`
|
||||||
|
18. **Press** (`/press`) - `NEXT_PUBLIC_IFRAME_SHOWCASE_URL`
|
||||||
|
19. **Observatory** - `NEXT_PUBLIC_IFRAME_OBSERVATORY_URL`
|
||||||
|
20. **Time Tracker** - `NEXT_PUBLIC_IFRAME_TIMETRACKER_URL`
|
||||||
|
21. **Missions Board** - `NEXT_PUBLIC_IFRAME_MISSIONSBOARD_URL`
|
||||||
|
22. **Carnet** - `NEXT_PUBLIC_IFRAME_CARNET_URL`
|
||||||
|
|
||||||
|
### 4.3 SSO Cookie Mechanism
|
||||||
|
|
||||||
|
**How It Works**:
|
||||||
|
|
||||||
|
1. **Parent Dashboard Authentication**:
|
||||||
|
- User authenticates via Keycloak in the dashboard
|
||||||
|
- Keycloak sets authentication cookies (domain: Keycloak domain)
|
||||||
|
- NextAuth sets session cookies (domain: dashboard domain)
|
||||||
|
|
||||||
|
2. **Iframe Cookie Sharing**:
|
||||||
|
- When iframe loads, browser sends cookies for the iframe's domain
|
||||||
|
- If iframe application is on **same domain** or **subdomain** of Keycloak:
|
||||||
|
- Keycloak cookies are automatically sent
|
||||||
|
- Application can read Keycloak session cookies
|
||||||
|
- SSO works automatically
|
||||||
|
|
||||||
|
3. **Cross-Domain Considerations**:
|
||||||
|
- If iframe apps are on different domains, they need:
|
||||||
|
- Same Keycloak realm configuration
|
||||||
|
- Proper CORS settings
|
||||||
|
- Cookie domain configuration in Keycloak
|
||||||
|
- `SameSite=None; Secure` cookie attributes for cross-site
|
||||||
|
|
||||||
|
### 4.4 ResponsiveIframe Component
|
||||||
|
|
||||||
|
**Location**: `app/components/responsive-iframe.tsx`
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Auto-resizing based on viewport
|
||||||
|
- Hash synchronization (URL fragments)
|
||||||
|
- Full-screen support
|
||||||
|
|
||||||
|
**Important**: This component does **NOT** handle authentication - it's purely presentational. SSO relies on browser cookie behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Sign-Out Flow
|
||||||
|
|
||||||
|
### 5.1 Sign-Out Page
|
||||||
|
|
||||||
|
**Location**: `app/signout/page.tsx`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
export default function SignOut() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SignOutHandler />
|
||||||
|
<p>Déconnexion en cours...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Sign-Out Handler
|
||||||
|
|
||||||
|
**Location**: `components/auth/signout-handler.tsx`
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
```typescript
|
||||||
|
1. clearAuthCookies() - Clears NextAuth cookies client-side
|
||||||
|
2. signOut({ callbackUrl: "/signin", redirect: true })
|
||||||
|
- Calls NextAuth signout endpoint
|
||||||
|
- Invalidates session
|
||||||
|
- Redirects to /signin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Cookie Clearing
|
||||||
|
|
||||||
|
**Location**: `lib/session.ts` - `clearAuthCookies()`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
export function clearAuthCookies() {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const [name] = cookie.split('=');
|
||||||
|
if (name.trim().startsWith('next-auth.') ||
|
||||||
|
name.trim().startsWith('__Secure-next-auth.') ||
|
||||||
|
name.trim().startsWith('__Host-next-auth.')) {
|
||||||
|
document.cookie = `${name.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This only clears NextAuth cookies. Keycloak cookies remain unless:
|
||||||
|
- User manually logs out of Keycloak
|
||||||
|
- Keycloak session expires
|
||||||
|
- Application calls Keycloak logout endpoint
|
||||||
|
|
||||||
|
### 5.4 Service Token Invalidation
|
||||||
|
|
||||||
|
**Location**: `lib/session.ts` - `invalidateServiceTokens()`
|
||||||
|
|
||||||
|
**Purpose**: Logs out from integrated services (RocketChat, Leantime, etc.)
|
||||||
|
|
||||||
|
**Services Handled**:
|
||||||
|
- RocketChat: `/api/v1/logout`
|
||||||
|
- Leantime: JSON-RPC logout method
|
||||||
|
|
||||||
|
**Note**: This function exists but may not be called during standard sign-out flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Server-Side Session Access
|
||||||
|
|
||||||
|
### 6.1 getServerSession()
|
||||||
|
|
||||||
|
**Usage Pattern** (seen in all iframe pages):
|
||||||
|
```typescript
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/app/api/auth/options";
|
||||||
|
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
**How It Works**:
|
||||||
|
1. Reads `next-auth.session-token` cookie from request
|
||||||
|
2. Decrypts JWT using `NEXTAUTH_SECRET`
|
||||||
|
3. Validates token signature and expiration
|
||||||
|
4. If expired, triggers refresh (via JWT callback)
|
||||||
|
5. Returns session object
|
||||||
|
|
||||||
|
**Location**: Used in:
|
||||||
|
- All iframe page components
|
||||||
|
- Root layout (`app/layout.tsx`)
|
||||||
|
- Any server component needing authentication
|
||||||
|
|
||||||
|
### 6.2 Client-Side Session Access
|
||||||
|
|
||||||
|
**Usage Pattern**:
|
||||||
|
```typescript
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
```
|
||||||
|
|
||||||
|
**How It Works**:
|
||||||
|
1. `useSession()` hook calls `/api/auth/session`
|
||||||
|
2. Server decrypts JWT and returns session
|
||||||
|
3. Client receives session object
|
||||||
|
4. Automatically refetches when token refreshes
|
||||||
|
|
||||||
|
**Location**: Used in:
|
||||||
|
- `app/signin/page.tsx`
|
||||||
|
- `components/auth/auth-check.tsx`
|
||||||
|
- Any client component needing authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Keycloak Admin Client
|
||||||
|
|
||||||
|
### 7.1 Purpose
|
||||||
|
|
||||||
|
**Location**: `lib/keycloak.ts`
|
||||||
|
|
||||||
|
The Keycloak Admin Client is used for **server-side user management**, not for user authentication. It's a separate administrative interface.
|
||||||
|
|
||||||
|
### 7.2 Authentication Methods
|
||||||
|
|
||||||
|
**Two Methods Supported**:
|
||||||
|
|
||||||
|
1. **Client Credentials** (Preferred):
|
||||||
|
```typescript
|
||||||
|
grant_type: 'client_credentials'
|
||||||
|
client_id: KEYCLOAK_CLIENT_ID
|
||||||
|
client_secret: KEYCLOAK_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Password Grant** (Fallback):
|
||||||
|
```typescript
|
||||||
|
grant_type: 'password'
|
||||||
|
client_id: KEYCLOAK_CLIENT_ID
|
||||||
|
username: KEYCLOAK_ADMIN_USERNAME
|
||||||
|
password: KEYCLOAK_ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Caching
|
||||||
|
|
||||||
|
**Token Caching**: 5 minutes
|
||||||
|
- Validates cached token before reuse
|
||||||
|
- Creates new client if token invalid/expired
|
||||||
|
|
||||||
|
### 7.4 Functions
|
||||||
|
|
||||||
|
- `getKeycloakAdminClient()` - Get authenticated admin client
|
||||||
|
- `getUserById(userId)` - Get user by Keycloak ID
|
||||||
|
- `getUserByEmail(email)` - Get user by email
|
||||||
|
- `getAllRoles()` - Get all realm roles
|
||||||
|
- `getUserRoles(userId)` - Get user's role mappings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Considerations
|
||||||
|
|
||||||
|
### 8.1 Cookie Security
|
||||||
|
|
||||||
|
**NextAuth Cookie Configuration** (implicit):
|
||||||
|
- **HttpOnly**: Yes (prevents XSS access)
|
||||||
|
- **Secure**: Yes (if `NEXTAUTH_URL` starts with `https://`)
|
||||||
|
- **SameSite**: Lax (default)
|
||||||
|
- **Path**: `/`
|
||||||
|
- **Domain**: Dashboard domain
|
||||||
|
|
||||||
|
**Keycloak Cookie Configuration** (Keycloak-controlled):
|
||||||
|
- Set by Keycloak server
|
||||||
|
- Typically `SameSite=Lax` or `SameSite=None` (for cross-site)
|
||||||
|
- Domain: Keycloak domain or configured domain
|
||||||
|
|
||||||
|
### 8.2 Token Storage
|
||||||
|
|
||||||
|
- **Access Token**: Stored in encrypted JWT (server-side only accessible)
|
||||||
|
- **Refresh Token**: Stored in encrypted JWT
|
||||||
|
- **Session Token**: Encrypted JWT in HTTP-only cookie
|
||||||
|
|
||||||
|
**Client-Side Access**:
|
||||||
|
- `session.accessToken` is exposed to client via `useSession()`
|
||||||
|
- This is the Keycloak OAuth access token
|
||||||
|
- Can be used for API calls to Keycloak-protected resources
|
||||||
|
|
||||||
|
### 8.3 CORS & CSP
|
||||||
|
|
||||||
|
**Content Security Policy** (`next.config.mjs`):
|
||||||
|
```typescript
|
||||||
|
'Content-Security-Policy': "frame-ancestors 'self' https://espace.slm-lab.net https://connect.slm-lab.net"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Allows framing from**:
|
||||||
|
- Same origin (`'self'`)
|
||||||
|
- `https://espace.slm-lab.net`
|
||||||
|
- `https://connect.slm-lab.net`
|
||||||
|
|
||||||
|
### 8.4 Role-Based Access Control
|
||||||
|
|
||||||
|
**Role Extraction**:
|
||||||
|
- Roles come from Keycloak `realm_access.roles`
|
||||||
|
- Cleaned: `ROLE_` prefix removed, lowercased
|
||||||
|
- Stored in session: `session.user.role[]`
|
||||||
|
|
||||||
|
**Usage**: Roles are available but not actively enforced in the codebase audit. Applications should implement RBAC checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Environment Variables
|
||||||
|
|
||||||
|
### 9.1 Required for Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Keycloak OAuth Configuration
|
||||||
|
KEYCLOAK_CLIENT_ID=neah-dashboard
|
||||||
|
KEYCLOAK_CLIENT_SECRET=<secret>
|
||||||
|
KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah
|
||||||
|
KEYCLOAK_REALM=neah
|
||||||
|
|
||||||
|
# NextAuth Configuration
|
||||||
|
NEXTAUTH_URL=https://dashboard.example.com
|
||||||
|
NEXTAUTH_SECRET=<random-secret>
|
||||||
|
|
||||||
|
# Keycloak Admin (optional, for user management)
|
||||||
|
KEYCLOAK_ADMIN_USERNAME=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=<password>
|
||||||
|
KEYCLOAK_BASE_URL=https://keycloak.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Iframe Application URLs
|
||||||
|
|
||||||
|
All iframe applications require `NEXT_PUBLIC_IFRAME_*` environment variables:
|
||||||
|
- `NEXT_PUBLIC_IFRAME_PAROLE_URL`
|
||||||
|
- `NEXT_PUBLIC_IFRAME_AGILITY_URL`
|
||||||
|
- `NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL`
|
||||||
|
- `NEXT_PUBLIC_IFRAME_CONFERENCE_URL`
|
||||||
|
- ... (see section 4.2 for complete list)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Potential Issues & Recommendations
|
||||||
|
|
||||||
|
### 10.1 Token Expiration Bug
|
||||||
|
|
||||||
|
**Location**: `app/api/auth/options.ts:176`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue**: `accessTokenExpires` from Keycloak `account.expires_at` is already in seconds since epoch. Multiplying by 1000 assumes it's in milliseconds, which may cause incorrect expiration checks.
|
||||||
|
|
||||||
|
**Recommendation**: Verify Keycloak's `expires_at` format. If it's in seconds, remove the `* 1000`. If it's in milliseconds, keep it.
|
||||||
|
|
||||||
|
### 10.2 Cookie SameSite for Cross-Domain Iframes
|
||||||
|
|
||||||
|
**Issue**: If iframe applications are on different domains, Keycloak cookies may not be sent due to `SameSite` restrictions.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Configure Keycloak cookies with `SameSite=None; Secure`
|
||||||
|
- Ensure all domains use HTTPS
|
||||||
|
- Consider using a shared parent domain for cookies
|
||||||
|
|
||||||
|
### 10.3 Access Token Exposure
|
||||||
|
|
||||||
|
**Issue**: `session.accessToken` (Keycloak OAuth token) is exposed client-side.
|
||||||
|
|
||||||
|
**Recommendation**:
|
||||||
|
- Only expose if needed for client-side API calls
|
||||||
|
- Consider using proxy endpoints instead
|
||||||
|
- Implement token rotation if exposed
|
||||||
|
|
||||||
|
### 10.4 No Explicit Cookie Configuration
|
||||||
|
|
||||||
|
**Issue**: NextAuth cookie settings are implicit (defaults).
|
||||||
|
|
||||||
|
**Recommendation**: Explicitly configure cookies in `authOptions`:
|
||||||
|
```typescript
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 Storage Initialization
|
||||||
|
|
||||||
|
**Issue**: Storage initialization happens client-side after authentication, which may cause race conditions.
|
||||||
|
|
||||||
|
**Recommendation**: Move storage initialization to server-side or use a more robust initialization pattern.
|
||||||
|
|
||||||
|
### 10.6 Service Token Invalidation Not Called
|
||||||
|
|
||||||
|
**Issue**: `invalidateServiceTokens()` exists but may not be called during sign-out.
|
||||||
|
|
||||||
|
**Recommendation**: Integrate service token invalidation into the sign-out flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Flow Diagrams
|
||||||
|
|
||||||
|
### 11.1 Complete Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User → /signin
|
||||||
|
→ signIn("keycloak")
|
||||||
|
→ /api/auth/signin/keycloak
|
||||||
|
→ Keycloak Authorization Endpoint
|
||||||
|
→ User Login (Keycloak)
|
||||||
|
→ Keycloak Token Endpoint
|
||||||
|
→ /api/auth/callback/keycloak
|
||||||
|
→ JWT Callback (store tokens)
|
||||||
|
→ Session Callback (build session)
|
||||||
|
→ Set Cookies
|
||||||
|
→ Redirect to /
|
||||||
|
→ Storage Init
|
||||||
|
→ Dashboard Loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Iframe SSO Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard (authenticated)
|
||||||
|
→ User clicks iframe app link
|
||||||
|
→ Server checks session (getServerSession)
|
||||||
|
→ If authenticated: Load iframe
|
||||||
|
→ Browser sends cookies to iframe domain
|
||||||
|
→ Iframe app reads Keycloak cookies
|
||||||
|
→ Iframe app validates session
|
||||||
|
→ Iframe app loads authenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Token Refresh Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Request with expired token
|
||||||
|
→ getServerSession()
|
||||||
|
→ Decrypt JWT
|
||||||
|
→ Check expiration
|
||||||
|
→ If expired: JWT Callback
|
||||||
|
→ refreshAccessToken()
|
||||||
|
→ POST to Keycloak /token
|
||||||
|
→ Get new tokens
|
||||||
|
→ Update JWT
|
||||||
|
→ Return session
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. File Reference Map
|
||||||
|
|
||||||
|
### Core Authentication Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/api/auth/[...nextauth]/route.ts` | NextAuth route handler |
|
||||||
|
| `app/api/auth/options.ts` | **Main auth configuration** |
|
||||||
|
| `app/signin/page.tsx` | Sign-in page |
|
||||||
|
| `app/signout/page.tsx` | Sign-out page |
|
||||||
|
| `components/auth/signout-handler.tsx` | Sign-out logic |
|
||||||
|
| `components/auth/auth-check.tsx` | Client-side auth guard |
|
||||||
|
| `lib/keycloak.ts` | Keycloak admin client |
|
||||||
|
| `lib/session.ts` | Session utilities |
|
||||||
|
| `types/next-auth.d.ts` | TypeScript definitions |
|
||||||
|
|
||||||
|
### Iframe Application Files
|
||||||
|
|
||||||
|
All in `app/*/page.tsx`:
|
||||||
|
- `app/parole/page.tsx`
|
||||||
|
- `app/agilite/page.tsx`
|
||||||
|
- `app/alma/page.tsx`
|
||||||
|
- `app/vision/page.tsx`
|
||||||
|
- ... (see section 4.2)
|
||||||
|
|
||||||
|
### Supporting Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `app/components/responsive-iframe.tsx` | Iframe component |
|
||||||
|
| `app/layout.tsx` | Root layout (session check) |
|
||||||
|
| `components/providers.tsx` | SessionProvider wrapper |
|
||||||
|
| `components/layout/layout-wrapper.tsx` | Layout wrapper with auth |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Testing Checklist
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
- [ ] Sign-in redirects to Keycloak
|
||||||
|
- [ ] Keycloak login works
|
||||||
|
- [ ] Callback receives tokens
|
||||||
|
- [ ] Session is created
|
||||||
|
- [ ] Cookies are set
|
||||||
|
- [ ] User redirected to dashboard
|
||||||
|
- [ ] Storage initializes
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- [ ] Session persists across page reloads
|
||||||
|
- [ ] Token refresh works when expired
|
||||||
|
- [ ] Session expires after 30 days
|
||||||
|
- [ ] Invalid tokens are rejected
|
||||||
|
|
||||||
|
### Sign-Out
|
||||||
|
- [ ] Sign-out clears NextAuth cookies
|
||||||
|
- [ ] User redirected to sign-in
|
||||||
|
- [ ] Session invalidated
|
||||||
|
|
||||||
|
### Iframe SSO
|
||||||
|
- [ ] Iframe apps receive Keycloak cookies
|
||||||
|
- [ ] Iframe apps authenticate automatically
|
||||||
|
- [ ] Cross-domain cookies work (if applicable)
|
||||||
|
- [ ] Unauthenticated users redirected
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [ ] HttpOnly cookies enforced
|
||||||
|
- [ ] Secure cookies on HTTPS
|
||||||
|
- [ ] CSRF protection active
|
||||||
|
- [ ] Token encryption working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Conclusion
|
||||||
|
|
||||||
|
The authentication architecture uses a standard NextAuth + Keycloak OAuth 2.0 flow with JWT-based sessions. The system supports SSO for iframe applications via cookie sharing, assuming proper domain configuration.
|
||||||
|
|
||||||
|
**Key Strengths**:
|
||||||
|
- Standard OAuth 2.0/OpenID Connect implementation
|
||||||
|
- Stateless JWT sessions (scalable)
|
||||||
|
- Automatic token refresh
|
||||||
|
- Role-based user information
|
||||||
|
|
||||||
|
**Areas for Improvement**:
|
||||||
|
- Explicit cookie configuration
|
||||||
|
- Token expiration bug fix
|
||||||
|
- Service token invalidation integration
|
||||||
|
- Cross-domain cookie configuration verification
|
||||||
|
- Storage initialization robustness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version**: 1.0
|
||||||
|
**Last Updated**: 2024
|
||||||
|
**Audited By**: AI Assistant
|
||||||
|
**Next Review**: After implementing recommendations
|
||||||
|
|
||||||
@ -36,12 +36,14 @@ declare module "next-auth" {
|
|||||||
nextcloudInitialized?: boolean;
|
nextcloudInitialized?: boolean;
|
||||||
};
|
};
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
idToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JWT {
|
interface JWT {
|
||||||
sub?: string;
|
sub?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
accessTokenExpires?: number;
|
accessTokenExpires?: number;
|
||||||
role?: string[];
|
role?: string[];
|
||||||
username?: string;
|
username?: string;
|
||||||
@ -84,6 +86,8 @@ async function refreshAccessToken(token: JWT) {
|
|||||||
...token,
|
...token,
|
||||||
accessToken: refreshedTokens.access_token,
|
accessToken: refreshedTokens.access_token,
|
||||||
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
|
||||||
|
// Keep existing ID token (Keycloak doesn't return new ID token on refresh)
|
||||||
|
idToken: token.idToken,
|
||||||
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -152,6 +156,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
token.accessToken = account.access_token ?? '';
|
token.accessToken = account.access_token ?? '';
|
||||||
token.refreshToken = account.refresh_token ?? '';
|
token.refreshToken = account.refresh_token ?? '';
|
||||||
|
token.idToken = account.id_token ?? '';
|
||||||
token.accessTokenExpires = account.expires_at ?? 0;
|
token.accessTokenExpires = account.expires_at ?? 0;
|
||||||
token.sub = keycloakProfile.sub;
|
token.sub = keycloakProfile.sub;
|
||||||
token.role = cleanRoles;
|
token.role = cleanRoles;
|
||||||
@ -197,6 +202,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
nextcloudInitialized: false,
|
nextcloudInitialized: false,
|
||||||
};
|
};
|
||||||
session.accessToken = token.accessToken;
|
session.accessToken = token.accessToken;
|
||||||
|
session.idToken = token.idToken;
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,26 @@
|
|||||||
|
|
||||||
import { signIn, useSession } from "next-auth/react";
|
import { signIn, useSession } from "next-auth/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function SignIn() {
|
export default function SignIn() {
|
||||||
const { data: session } = useSession();
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
const [initializationStatus, setInitializationStatus] = useState<string | null>(null);
|
const [initializationStatus, setInitializationStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Trigger Keycloak sign-in
|
// If user is already authenticated, redirect to home
|
||||||
signIn("keycloak", { callbackUrl: "/" });
|
if (status === "authenticated" && session?.user) {
|
||||||
}, []);
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only trigger Keycloak sign-in if not authenticated and not loading
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
// Trigger Keycloak sign-in
|
||||||
|
signIn("keycloak", { callbackUrl: "/" });
|
||||||
|
}
|
||||||
|
}, [status, session, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
|
|||||||
@ -1,24 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { clearAuthCookies } from "@/lib/session";
|
import { clearAuthCookies } from "@/lib/session";
|
||||||
|
|
||||||
export function SignOutHandler() {
|
export function SignOutHandler() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
// Clear only auth-related cookies
|
try {
|
||||||
clearAuthCookies();
|
// Get Keycloak issuer from environment
|
||||||
|
const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
|
||||||
|
const idToken = session?.idToken;
|
||||||
|
|
||||||
// Then sign out from NextAuth
|
// First, sign out from NextAuth (clears NextAuth cookies)
|
||||||
await signOut({
|
await signOut({
|
||||||
callbackUrl: "/signin",
|
callbackUrl: "/signin",
|
||||||
redirect: true
|
redirect: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear NextAuth cookies client-side
|
||||||
|
clearAuthCookies();
|
||||||
|
|
||||||
|
// If we have Keycloak ID token and issuer, call Keycloak logout
|
||||||
|
if (keycloakIssuer && idToken) {
|
||||||
|
const keycloakLogoutUrl = new URL(
|
||||||
|
`${keycloakIssuer}/protocol/openid-connect/logout`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add required parameters
|
||||||
|
keycloakLogoutUrl.searchParams.append(
|
||||||
|
'post_logout_redirect_uri',
|
||||||
|
window.location.origin + '/signin'
|
||||||
|
);
|
||||||
|
keycloakLogoutUrl.searchParams.append(
|
||||||
|
'id_token_hint',
|
||||||
|
idToken
|
||||||
|
);
|
||||||
|
|
||||||
|
// Redirect to Keycloak logout (this will clear Keycloak cookies)
|
||||||
|
window.location.href = keycloakLogoutUrl.toString();
|
||||||
|
} else {
|
||||||
|
// Fallback: just redirect to signin if we don't have Keycloak info
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during sign out:', error);
|
||||||
|
// Fallback: redirect to signin on error
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSignOut();
|
handleSignOut();
|
||||||
}, []);
|
}, [session]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -362,29 +362,37 @@ export function MainNav() {
|
|||||||
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
|
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
// First sign out from NextAuth
|
const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
|
||||||
|
const idToken = session?.idToken;
|
||||||
|
|
||||||
|
// First sign out from NextAuth (clears NextAuth cookies)
|
||||||
await signOut({
|
await signOut({
|
||||||
callbackUrl: '/signin',
|
callbackUrl: '/signin',
|
||||||
redirect: false
|
redirect: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then redirect to Keycloak logout with proper parameters
|
// If we have Keycloak ID token and issuer, call Keycloak logout
|
||||||
const keycloakLogoutUrl = new URL(
|
if (keycloakIssuer && idToken) {
|
||||||
`${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`
|
const keycloakLogoutUrl = new URL(
|
||||||
);
|
`${keycloakIssuer}/protocol/openid-connect/logout`
|
||||||
|
);
|
||||||
|
|
||||||
// Add required parameters
|
// Add required parameters
|
||||||
keycloakLogoutUrl.searchParams.append(
|
keycloakLogoutUrl.searchParams.append(
|
||||||
'post_logout_redirect_uri',
|
'post_logout_redirect_uri',
|
||||||
window.location.origin
|
window.location.origin + '/signin'
|
||||||
);
|
);
|
||||||
keycloakLogoutUrl.searchParams.append(
|
keycloakLogoutUrl.searchParams.append(
|
||||||
'id_token_hint',
|
'id_token_hint',
|
||||||
session?.accessToken || ''
|
idToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// Redirect to Keycloak logout
|
// Redirect to Keycloak logout (this will clear Keycloak cookies)
|
||||||
window.location.href = keycloakLogoutUrl.toString();
|
window.location.href = keycloakLogoutUrl.toString();
|
||||||
|
} else {
|
||||||
|
// Fallback: just redirect to signin if we don't have Keycloak info
|
||||||
|
window.location.href = '/signin';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during logout:', error);
|
console.error('Error during logout:', error);
|
||||||
// Fallback to simple redirect if something goes wrong
|
// Fallback to simple redirect if something goes wrong
|
||||||
|
|||||||
3
types/next-auth.d.ts
vendored
3
types/next-auth.d.ts
vendored
@ -12,6 +12,7 @@ declare module "next-auth" {
|
|||||||
} & DefaultSession["user"];
|
} & DefaultSession["user"];
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
rocketChatToken?: string | null;
|
rocketChatToken?: string | null;
|
||||||
rocketChatUserId?: string | null;
|
rocketChatUserId?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -20,6 +21,7 @@ declare module "next-auth" {
|
|||||||
interface JWT {
|
interface JWT {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
accessTokenExpires?: number;
|
accessTokenExpires?: number;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
@ -55,6 +57,7 @@ declare module "next-auth/jwt" {
|
|||||||
interface JWT {
|
interface JWT {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
idToken?: string;
|
||||||
accessTokenExpires?: number;
|
accessTokenExpires?: number;
|
||||||
first_name?: string;
|
first_name?: string;
|
||||||
last_name?: string;
|
last_name?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user