251 lines
8.8 KiB
Markdown
251 lines
8.8 KiB
Markdown
# SSO Flow Analysis - Keycloak External Logout Issue
|
|
|
|
## Current Flow Trace
|
|
|
|
### Scenario: User logs out from Keycloak directly, then accesses dashboard
|
|
|
|
**Step-by-step flow:**
|
|
|
|
1. **Initial State (Before Keycloak Logout)**
|
|
- User is logged into Dashboard via NextAuth
|
|
- NextAuth JWT contains:
|
|
- `accessToken`: Valid Keycloak OAuth token
|
|
- `refreshToken`: Valid Keycloak refresh token
|
|
- `idToken`: Valid Keycloak ID token
|
|
- Keycloak session cookies are set in browser
|
|
- Iframe applications can authenticate via Keycloak cookies
|
|
|
|
2. **User Logs Out from Keycloak Directly (External Application)**
|
|
- External application calls: `POST /realms/{realm}/protocol/openid-connect/logout`
|
|
- Keycloak invalidates:
|
|
- ✅ Keycloak session cookies (cleared)
|
|
- ✅ Keycloak refresh token (invalidated)
|
|
- ✅ Keycloak access token (invalidated)
|
|
- ❌ **NextAuth JWT still contains old tokens** (NextAuth doesn't know about logout)
|
|
- ❌ **NextAuth session cookie still valid** (30-day expiration)
|
|
|
|
3. **User Accesses Dashboard**
|
|
- Browser sends NextAuth session cookie
|
|
- NextAuth decrypts JWT
|
|
- JWT contains old (now invalid) tokens
|
|
- **Token expiration check**: `Date.now() < (token.accessTokenExpires as number) * 1000`
|
|
- If token hasn't expired yet (by timestamp), NextAuth returns existing token
|
|
- **Problem**: Token is invalid in Keycloak, but NextAuth doesn't know yet
|
|
|
|
4. **User Navigates to Iframe Application**
|
|
- `ResponsiveIframe` component mounts
|
|
- `useEffect` triggers: `refreshSession()`
|
|
- Calls: `GET /api/auth/refresh-keycloak-session`
|
|
|
|
5. **Refresh Endpoint Execution**
|
|
```
|
|
GET /api/auth/refresh-keycloak-session
|
|
→ getServerSession(authOptions)
|
|
→ Reads NextAuth JWT from cookie
|
|
→ JWT contains old refreshToken (invalid)
|
|
→ Calls Keycloak: POST /token with old refreshToken
|
|
→ Keycloak responds: { error: 'invalid_grant', error_description: 'Token is not active' }
|
|
→ Returns 401 with SessionInvalidated error
|
|
```
|
|
|
|
6. **ResponsiveIframe Handles Error**
|
|
- Detects `SessionInvalidated` error
|
|
- Redirects to `/signin`
|
|
- User signs in again
|
|
- Gets NEW tokens from Keycloak
|
|
|
|
7. **User Returns to Iframe (After Re-login)**
|
|
- **Problem**: If NextAuth JWT callback hasn't run yet, it might still have old tokens
|
|
- OR: The new session is created, but iframe component might be using cached session
|
|
- OR: The refresh endpoint is called again before new session is fully established
|
|
|
|
## Root Cause Analysis
|
|
|
|
### Issue 1: Stale Token Detection
|
|
|
|
**Problem**: NextAuth only tries to refresh tokens when they're expired (by timestamp). If a token is invalidated externally (Keycloak logout), NextAuth won't know until it tries to refresh.
|
|
|
|
**Current Flow**:
|
|
```
|
|
JWT Callback:
|
|
if (Date.now() < token.accessTokenExpires * 1000) {
|
|
return token; // Returns stale token without checking Keycloak
|
|
}
|
|
// Only refreshes if expired by timestamp
|
|
```
|
|
|
|
**What Should Happen**:
|
|
- When accessing iframe, we proactively refresh to validate token
|
|
- But if refresh fails, we need to clear the NextAuth session immediately
|
|
|
|
### Issue 2: Session Invalidation Timing
|
|
|
|
**Problem**: When refresh fails:
|
|
1. Refresh endpoint returns `SessionInvalidated`
|
|
2. ResponsiveIframe redirects to `/signin`
|
|
3. User signs in, gets new tokens
|
|
4. **But**: NextAuth JWT might still have old tokens cached until next JWT callback execution
|
|
|
|
**Current Behavior**:
|
|
- Redirect to signin happens
|
|
- User re-authenticates
|
|
- New session is created
|
|
- But old session might still be in browser cache/cookies
|
|
|
|
### Issue 3: Infinite Redirect Loop Potential
|
|
|
|
**Problem**: If the refresh endpoint keeps failing:
|
|
- ResponsiveIframe redirects to `/signin`
|
|
- User signs in
|
|
- Returns to iframe
|
|
- Refresh endpoint called again
|
|
- If new session isn't fully established, it might still use old tokens
|
|
- Loop continues
|
|
|
|
## Current Code Flow
|
|
|
|
### ResponsiveIframe Component Flow
|
|
|
|
```typescript
|
|
1. Component mounts with session
|
|
2. useEffect triggers refreshSession()
|
|
3. Calls GET /api/auth/refresh-keycloak-session
|
|
4. If 401 + SessionInvalidated:
|
|
→ window.location.href = '/signin'
|
|
→ User redirected
|
|
5. User signs in again
|
|
6. Returns to iframe page
|
|
7. Component mounts again
|
|
8. useEffect triggers refreshSession() again
|
|
9. If session still has old tokens → fails again
|
|
```
|
|
|
|
### Refresh Endpoint Flow
|
|
|
|
```typescript
|
|
GET /api/auth/refresh-keycloak-session
|
|
1. getServerSession(authOptions)
|
|
→ Reads JWT from cookie
|
|
→ JWT callback runs
|
|
→ If token expired: refreshAccessToken()
|
|
→ If token not expired: returns existing token (might be invalid!)
|
|
2. Uses session.refreshToken
|
|
3. Calls Keycloak refresh endpoint
|
|
4. If invalid_grant: Returns SessionInvalidated
|
|
```
|
|
|
|
### JWT Callback Flow
|
|
|
|
```typescript
|
|
async jwt({ token, account, profile }) {
|
|
// Initial login: account & profile present
|
|
if (account && profile) {
|
|
// Store tokens
|
|
}
|
|
|
|
// Subsequent requests
|
|
else if (token.accessToken) {
|
|
// Check expiration
|
|
if (Date.now() < token.accessTokenExpires * 1000) {
|
|
return token; // ⚠️ Returns token without validating with Keycloak
|
|
}
|
|
|
|
// Only refreshes if expired by timestamp
|
|
return refreshAccessToken(token);
|
|
}
|
|
}
|
|
```
|
|
|
|
## The Problem
|
|
|
|
**Key Issue**: NextAuth JWT callback only checks token expiration by timestamp. It doesn't validate that the token is still valid in Keycloak. So:
|
|
|
|
1. User logs out from Keycloak → Token invalidated
|
|
2. NextAuth JWT still has token (not expired by timestamp)
|
|
3. JWT callback returns existing token (assumes it's valid)
|
|
4. Refresh endpoint tries to use invalid refresh token
|
|
5. Fails, redirects to signin
|
|
6. User signs in, but if JWT callback hasn't run with new account, might still have old token
|
|
|
|
## Why It Gets Stuck
|
|
|
|
Looking at the logs:
|
|
```
|
|
Failed to refresh Keycloak session: { error: 'invalid_grant', error_description: 'Token is not active' }
|
|
GET /api/auth/refresh-keycloak-session 401
|
|
→ Redirects to /signin
|
|
→ User signs in
|
|
→ Returns to iframe
|
|
→ refresh-keycloak-session called again
|
|
→ Still fails (401)
|
|
```
|
|
|
|
**Possible reasons**:
|
|
1. **Session not fully updated**: After signin, NextAuth creates new session, but refresh endpoint might be reading old session from cookie before it's updated
|
|
2. **Token not refreshed in JWT**: The new tokens from signin might not be stored in JWT yet when refresh endpoint is called
|
|
3. **Cookie caching**: Browser might be sending old session cookie
|
|
4. **Race condition**: Refresh endpoint called before new session is established
|
|
|
|
## Recommendations (Without Code Changes)
|
|
|
|
### 1. Check Session State After Signin
|
|
|
|
After user signs in and is redirected back:
|
|
- Verify that `getServerSession()` returns new session with valid tokens
|
|
- Check that JWT callback has run and stored new tokens
|
|
- Ensure session cookie is updated in browser
|
|
|
|
### 2. Add Delay/Retry Logic
|
|
|
|
In ResponsiveIframe:
|
|
- After redirect from signin, wait a moment before calling refresh endpoint
|
|
- Or check if session has been updated before calling refresh
|
|
- Add retry logic with exponential backoff
|
|
|
|
### 3. Validate Token Before Using
|
|
|
|
In refresh endpoint:
|
|
- Before using refreshToken, validate that accessToken is still valid
|
|
- Or check token age - if token is old, force refresh even if not expired
|
|
|
|
### 4. Clear Session on Invalid Token
|
|
|
|
When refresh fails with invalid_grant:
|
|
- Don't just redirect - also clear NextAuth session cookie
|
|
- Force complete re-authentication
|
|
- Ensure old session is completely removed
|
|
|
|
### 5. Check Keycloak Session Status
|
|
|
|
Before calling refresh endpoint:
|
|
- Check if Keycloak session is still active
|
|
- Use Keycloak's userinfo endpoint to validate access token
|
|
- Only refresh if token is actually invalid
|
|
|
|
## Current Behavior Summary
|
|
|
|
**What's Happening**:
|
|
1. ✅ User logs out from Keycloak → Keycloak invalidates tokens
|
|
2. ✅ User accesses dashboard → NextAuth still has old tokens (not expired by timestamp)
|
|
3. ✅ User goes to iframe → Refresh endpoint called
|
|
4. ✅ Refresh fails → Detects invalid token
|
|
5. ✅ Redirects to signin → User re-authenticates
|
|
6. ⚠️ **Issue 1**: Storage initialization fails during signin (`createUserFolderStructure` not exported)
|
|
7. ⚠️ **Issue 2**: After re-authentication, refresh endpoint might still be using old session
|
|
8. ⚠️ **Result**: Gets stuck in redirect loop or keeps failing
|
|
|
|
**Root Cause**: NextAuth doesn't proactively validate tokens with Keycloak. It only checks expiration timestamps. When tokens are invalidated externally, NextAuth doesn't know until it tries to use them.
|
|
|
|
**Additional Issue Confirmed**:
|
|
- Storage initialization fails during signin process
|
|
- Error: `createUserFolderStructure is not a function`
|
|
- This prevents complete signin initialization
|
|
- May contribute to session not being fully established
|
|
|
|
---
|
|
|
|
**Analysis Date**: 2024
|
|
**Status**: Issue Identified
|
|
**Next Steps**: Implement proactive token validation or improve session invalidation handling
|
|
|