Update var

This commit is contained in:
alma 2026-01-01 17:40:16 +01:00
parent f56ca4e982
commit d2edd23c7a
8 changed files with 1310 additions and 35 deletions

4
.env
View File

@ -100,7 +100,7 @@ REDIS_PORT=6379
REDIS_PASSWORD=mySecretPassword
MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
MICROSOFT_CLIENT_SECRET="eIx8Q~N3ZnXTjTsVM3ECZio4G7t.BO6AYlD1-b2h"
MICROSOFT_REDIRECT_URI="https://lab.slm-lab.net/ms"
MICROSOFT_CLIENT_SECRET="GOO8Q~.~zJEz5xTSH4OnNgKe.DCuqr~IB~Gb~c0O"
MICROSOFT_REDIRECT_URI="https://hub.slm-lab.net/ms"
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
N8N_WEBHOOK_URL="https://brain.slm-lab.net/webhook-test/mission-created"

224
AUTHENTICATION_FIXES.md Normal file
View 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

View 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

View File

@ -36,12 +36,14 @@ declare module "next-auth" {
nextcloudInitialized?: boolean;
};
accessToken?: string;
idToken?: string;
}
interface JWT {
sub?: string;
accessToken?: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires?: number;
role?: string[];
username?: string;
@ -84,6 +86,8 @@ async function refreshAccessToken(token: JWT) {
...token,
accessToken: refreshedTokens.access_token,
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,
};
} catch (error) {
@ -152,6 +156,7 @@ export const authOptions: NextAuthOptions = {
token.accessToken = account.access_token ?? '';
token.refreshToken = account.refresh_token ?? '';
token.idToken = account.id_token ?? '';
token.accessTokenExpires = account.expires_at ?? 0;
token.sub = keycloakProfile.sub;
token.role = cleanRoles;
@ -197,6 +202,7 @@ export const authOptions: NextAuthOptions = {
nextcloudInitialized: false,
};
session.accessToken = token.accessToken;
session.idToken = token.idToken;
return session;
}

View File

@ -2,15 +2,26 @@
import { signIn, useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function SignIn() {
const { data: session } = useSession();
const { data: session, status } = useSession();
const router = useRouter();
const [initializationStatus, setInitializationStatus] = useState<string | null>(null);
useEffect(() => {
// Trigger Keycloak sign-in
signIn("keycloak", { callbackUrl: "/" });
}, []);
// If user is already authenticated, redirect to home
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(() => {
if (session?.user) {

View File

@ -1,24 +1,59 @@
"use client";
import { useEffect } from "react";
import { signOut } from "next-auth/react";
import { signOut, useSession } from "next-auth/react";
import { clearAuthCookies } from "@/lib/session";
export function SignOutHandler() {
const { data: session } = useSession();
useEffect(() => {
const handleSignOut = async () => {
// Clear only auth-related cookies
clearAuthCookies();
// Then sign out from NextAuth
await signOut({
callbackUrl: "/signin",
redirect: true
});
try {
// Get Keycloak issuer from environment
const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
const idToken = session?.idToken;
// First, sign out from NextAuth (clears NextAuth cookies)
await signOut({
callbackUrl: "/signin",
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();
}, []);
}, [session]);
return null;
}

View File

@ -362,29 +362,37 @@ export function MainNav() {
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={async () => {
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({
callbackUrl: '/signin',
redirect: false
});
// Then redirect to Keycloak logout with proper parameters
const keycloakLogoutUrl = new URL(
`${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`
);
// Add required parameters
keycloakLogoutUrl.searchParams.append(
'post_logout_redirect_uri',
window.location.origin
);
keycloakLogoutUrl.searchParams.append(
'id_token_hint',
session?.accessToken || ''
);
// Redirect to Keycloak logout
window.location.href = keycloakLogoutUrl.toString();
// 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 logout:', error);
// Fallback to simple redirect if something goes wrong

View File

@ -12,6 +12,7 @@ declare module "next-auth" {
} & DefaultSession["user"];
accessToken?: string;
refreshToken?: string;
idToken?: string;
rocketChatToken?: string | null;
rocketChatUserId?: string | null;
error?: string;
@ -20,6 +21,7 @@ declare module "next-auth" {
interface JWT {
accessToken?: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires?: number;
first_name?: string;
last_name?: string;
@ -55,6 +57,7 @@ declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires?: number;
first_name?: string;
last_name?: string;