NeahNew/SSO_FLOW_ANALYSIS.md
2026-01-02 14:32:36 +01:00

8.8 KiB

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

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

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

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