From d2edd23c7aa9cab19ab0f78e00bb2704e82063d1 Mon Sep 17 00:00:00 2001 From: alma Date: Thu, 1 Jan 2026 17:40:16 +0100 Subject: [PATCH] Update var --- .env | 4 +- AUTHENTICATION_FIXES.md | 224 +++++++ AUTHENTICATION_FLOW_AUDIT.md | 988 ++++++++++++++++++++++++++++ app/api/auth/options.ts | 6 + app/signin/page.tsx | 19 +- components/auth/signout-handler.tsx | 55 +- components/main-nav.tsx | 46 +- types/next-auth.d.ts | 3 + 8 files changed, 1310 insertions(+), 35 deletions(-) create mode 100644 AUTHENTICATION_FIXES.md create mode 100644 AUTHENTICATION_FLOW_AUDIT.md diff --git a/.env b/.env index 98072c8c..720d9cb9 100644 --- a/.env +++ b/.env @@ -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" diff --git a/AUTHENTICATION_FIXES.md b/AUTHENTICATION_FIXES.md new file mode 100644 index 00000000..c6f8d69b --- /dev/null +++ b/AUTHENTICATION_FIXES.md @@ -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: +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= +KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah +NEXTAUTH_URL=https://dashboard.example.com +NEXTAUTH_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 + diff --git a/AUTHENTICATION_FLOW_AUDIT.md b/AUTHENTICATION_FLOW_AUDIT.md new file mode 100644 index 00000000..c5a4be4e --- /dev/null +++ b/AUTHENTICATION_FLOW_AUDIT.md @@ -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: + ▼ +┌─────────────────────┐ +│ 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 ( + + ); +} +``` + +**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 ( +
+ +

Déconnexion en cours...

+
+ ); +} +``` + +### 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= +KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah +KEYCLOAK_REALM=neah + +# NextAuth Configuration +NEXTAUTH_URL=https://dashboard.example.com +NEXTAUTH_SECRET= + +# Keycloak Admin (optional, for user management) +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_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 + diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts index 331ea013..6925f87c 100644 --- a/app/api/auth/options.ts +++ b/app/api/auth/options.ts @@ -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; } diff --git a/app/signin/page.tsx b/app/signin/page.tsx index fd48095e..fcbbc272 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -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(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) { diff --git a/components/auth/signout-handler.tsx b/components/auth/signout-handler.tsx index bd28645c..282fd730 100644 --- a/components/auth/signout-handler.tsx +++ b/components/auth/signout-handler.tsx @@ -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; } \ No newline at end of file diff --git a/components/main-nav.tsx b/components/main-nav.tsx index 4be59a9f..912e79ba 100644 --- a/components/main-nav.tsx +++ b/components/main-nav.tsx @@ -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 diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts index b660b33d..5946f1c8 100644 --- a/types/next-auth.d.ts +++ b/types/next-auth.d.ts @@ -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;