989 lines
28 KiB
Markdown
989 lines
28 KiB
Markdown
# 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
|
|
|