- Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): handle oidc provider signout #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements OIDC RP-Initiated Logout to properly sign users out from both the local application session and the OIDC provider (Okta) SSO session. Previously, only local sign-out was performed, leaving the SSO session active.
Key Changes:
- Added OIDC discovery caching for the
end_session_endpointand ID token storage in the encrypted cookie for logout - Implemented server action
getOidcSignOutUrl()to construct the OIDC provider logout URL with proper parameters - Modified client-side
signOut()function to redirect users to the OIDC provider's logout endpoint before completing local cleanup
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
src/lib/auth/types.ts | Extended OidcTokenData to include idToken and updated OidcDiscovery interface with logout endpoint |
src/lib/auth/constants.ts | Made OIDC_ISSUER_URL private and added OIDC_DISCOVERY_URL constant; renamed cookie constant |
src/lib/auth/utils.ts | Added getOidcIdToken() function, updated saveAccountToken() to store ID token, fixed type guard for accessTokenExpiresAt |
src/lib/auth/auth.ts | Enhanced getOidcDiscovery() to cache logout endpoint, updated refreshAccessToken() signature to handle ID token, enabled debug mode |
src/lib/auth/actions.ts | New file with server actions for clearOidcTokenAction() and getOidcSignOutUrl() |
src/lib/auth/auth-client.ts | Refactored signOut() to implement OIDC logout flow with redirect to provider |
src/app/api/auth/refresh-token/route.ts | Added session validation before token refresh and user ID verification |
src/lib/auth/__tests__/*.test.ts | Updated test data to match new OidcTokenData structure with additional fields |
src/lib/auth/__tests__/auth-client.test.ts | New test suite for client-side sign-out flow |
src/app/catalog/page.tsx | Removed redundant auth check (already protected by getAuthenticatedClient()) |
src/app/signin/signin-button.tsx | Added cursor-pointer class to button |
biome.json | Updated schema version to 2.3.8 |
| "clearOidcTokenAction", | ||
| "window.location.replace", | ||
| "authClient.signOut", |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test validates incorrect behavior
This test verifies the execution order expects window.location.replace to happen before authClient.signOut (lines 94-99). However, window.location.replace() causes an immediate browser redirect, making any code after it unreachable.
This test passes because mocks don't actually redirect, but in real execution, authClient.signOut() would never be called. The test is validating broken behavior instead of catching the bug.
Expected order should be:
expect(callOrder).toEqual([ "getOidcSignOutUrl", "authClient.signOut", // Sign out BEFORE redirect "clearOidcTokenAction", "window.location.replace", // Redirect LAST ]);| "clearOidcTokenAction", | |
| "window.location.replace", | |
| "authClient.signOut", | |
| "authClient.signOut", | |
| "clearOidcTokenAction", | |
| "window.location.replace", |
| userId: account.userId, | ||
| }; | ||
| | ||
| console.log("[account] Token data to save:", JSON.stringify(account)); |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug logging should be removed or placed behind feature flag
This debug log statement logs the entire account object, which may contain sensitive data. Line 182 also enables debug: true globally. Debug logging should either:
- Be removed before production
- Be conditional based on
NODE_ENV !== 'production' - Avoid logging sensitive data like tokens
Suggested fix:
if (process.env.NODE_ENV !== 'production') { console.log("[account] Account saved with token"); }| return ( | ||
| typeof obj.accessToken === "string" && | ||
| typeof obj.expiresAt === "number" && | ||
| typeof obj.accessTokenExpiresAt === "number" && | ||
| typeof obj.userId === "string" && | ||
| (obj.refreshToken === undefined || typeof obj.refreshToken === "string") && | ||
| (obj.refreshTokenExpiresAt === undefined || | ||
| typeof obj.refreshTokenExpiresAt === "number") | ||
| ); |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type guard doesn't validate all required properties from OidcTokenData
The OidcTokenData interface (via Account extension) includes many properties like accessToken, refreshToken, userId, etc. However, this type guard only validates:
accessToken(string)accessTokenExpiresAt(number)userId(string)- Optional:
refreshToken,refreshTokenExpiresAt
According to the type definition, accessTokenExpiresAt is actually optional (accessTokenExpiresAt?: number), but this guard requires it to be present. This inconsistency could cause valid token data to be rejected.
Suggested fix:
return ( typeof obj.accessToken === "string" && (obj.accessTokenExpiresAt === undefined || typeof obj.accessTokenExpiresAt === "number") && typeof obj.userId === "string" && (obj.refreshToken === undefined || typeof obj.refreshToken === "string") && (obj.refreshTokenExpiresAt === undefined || typeof obj.refreshTokenExpiresAt === "number") );| if (cachedTokenEndpoint) { | ||
| return cachedTokenEndpoint; | ||
| return { | ||
| tokenEndpoint: cachedTokenEndpoint, | ||
| endSessionEndpoint: cachedEndSessionEndpoint, | ||
| }; | ||
| } |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incomplete cache invalidation logic
The function caches both cachedTokenEndpoint and cachedEndSessionEndpoint (lines 72-73), but only checks if cachedTokenEndpoint exists (line 53). If cachedEndSessionEndpoint fails to fetch but cachedTokenEndpoint succeeds, subsequent calls will return stale/null endSessionEndpoint.
Suggested fix:
if (cachedTokenEndpoint && cachedEndSessionEndpoint) { return { tokenEndpoint: cachedTokenEndpoint, endSessionEndpoint: cachedEndSessionEndpoint, }; }Or cache both values together as a single object.
| export interface OidcTokenData | ||
| extends Omit< | ||
| Account, | ||
| | "accessTokenExpiresAt" | ||
| | "accountId" | ||
| | "providerId" | ||
| | "refreshTokenExpiresAt" | ||
| | "createdAt" | ||
| | "updatedAt" | ||
| | "id" | ||
| > { | ||
| accessTokenExpiresAt?: number; | ||
| refreshTokenExpiresAt?: number; | ||
| userId: string; | ||
| providerId?: string; | ||
| accountId?: string; | ||
| updatedAt?: Date; | ||
| createdAt?: Date; | ||
| id?: string; | ||
| } |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type definition is overly complex and potentially inconsistent
The OidcTokenData interface extends Account while:
- Omitting several fields (
accessTokenExpiresAt,refreshTokenExpiresAt, etc.) - Then re-declaring them as optional with different types (e.g.,
accessTokenExpiresAt?: numberinstead ofDate)
This pattern is error-prone because:
- If
Accounttype changes in better-auth, this could break - The type conversion from
Datetonumberhappens at runtime insaveAccountToken, not at the type level - Optional fields that should be required (like
userId,accessToken) are being spread fromAccountwithout validation
Suggested approach:
Define OidcTokenData explicitly without extending Account:
export interface OidcTokenData { userId: string; accessToken: string; accessTokenExpiresAt?: number; refreshToken?: string; refreshTokenExpiresAt?: number; idToken?: string; // Include only fields you actually store }This makes the contract clear and avoids implicit dependencies on better-auth's internal types.
| // 2. Clear OIDC token cookie AFTER BA session is gone | ||
| await clearOidcTokenAction(); | ||
| | ||
| // 3. Redirect to OIDC provider logout | ||
| window.location.replace(redirectUrl); | ||
| // 4. Sign out from Better Auth (invalidates session) | ||
| await authClient.signOut(); |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical Bug: Sign-out order is incorrect
The sign-out flow has a critical logic error:
- Line 23: Gets logout URL ✓
- Line 26: Clears OIDC token cookie ✓
- Line 29:
window.location.replace()redirects immediately ❌ - Line 31:
authClient.signOut()never executes because the browser has already redirected
Expected order:
// 1. Get logout URL FIRST (while session exists) const redirectUrl = await getOidcSignOutUrl(); // 2. Sign out from Better Auth (invalidates session) await authClient.signOut(); // 3. Clear OIDC token cookie await clearOidcTokenAction(); // 4. Finally redirect to OIDC provider logout window.location.replace(redirectUrl);The comment on line 25 even states "Clear OIDC token cookie AFTER BA session is gone", but the Better Auth sign-out never happens.
| // 2. Clear OIDC token cookie AFTER BA session is gone | |
| await clearOidcTokenAction(); | |
| // 3. Redirect to OIDC provider logout | |
| window.location.replace(redirectUrl); | |
| // 4. Sign out from Better Auth (invalidates session) | |
| await authClient.signOut(); | |
| // 2. Sign out from Better Auth (invalidates session) | |
| await authClient.signOut(); | |
| // 3. Clear OIDC token cookie AFTER BA session is gone | |
| await clearOidcTokenAction(); | |
| // 4. Redirect to OIDC provider logout | |
| window.location.replace(redirectUrl); |
| const tokenData: OidcTokenData = { | ||
| ...account, | ||
| accessToken: account.accessToken, | ||
| refreshToken: account.refreshToken || undefined, | ||
| expiresAt, | ||
| accessTokenExpiresAt, | ||
| refreshTokenExpiresAt, | ||
| userId: account.userId, | ||
| }; |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Redundant property assignments after spread operator
Lines 148-153 use the spread operator ...account which already includes accessToken, refreshToken, and userId, then immediately reassign these same properties. This is redundant and could cause confusion.
Suggested fix:
const tokenData: OidcTokenData = { ...account, accessTokenExpiresAt, refreshTokenExpiresAt, refreshToken: account.refreshToken || undefined, };This ensures the timestamp conversions and undefined coercion for refreshToken are applied correctly without redundant assignments.
| import * as jose from "jose"; | ||
| import { TOKEN_ONE_HOUR_MS } from "./constants"; | ||
| import { cookies } from "next/headers"; | ||
| import { saveTokenCookie } from "./auth"; |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential circular dependency with saveTokenCookie import
The file imports saveTokenCookie from ./auth at line 9, and ./auth imports functions from ./utils. This creates a circular dependency pattern:
utils.tsimports fromauth.ts(line 9)auth.tsimports fromutils.ts(line 23)
While the previous code had a comment about avoiding this with dynamic imports, the new code removes the dynamic import strategy. This could cause module initialization issues.
Recommendation: Consider moving saveTokenCookie to a separate file (e.g., cookie.ts) or re-introducing the dynamic import to break the cycle.
| import { saveTokenCookie } from "./auth"; | |
| // import { saveTokenCookie } from "./auth"; // Removed to break circular dependency |
| } | ||
| | ||
| export const auth: Auth<BetterAuthOptions> = betterAuth({ | ||
| debug: true, |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Debug mode enabled in production
debug: true is enabled globally, which can expose sensitive authentication flow details in production logs. This should be conditional:
debug: process.env.NODE_ENV !== "production",| debug: true, | |
| debug: !IS_PRODUCTION, |
| const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; | ||
| export const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ""; | ||
| export const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ""; | ||
| export const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000"; | ||
| export const IS_PRODUCTION = process.env.NODE_ENV === "production"; | ||
| export const OIDC_DISCOVERY_URL = `${OIDC_ISSUER_URL}/.well-known/openid-configuration`; |
Copilot AI Dec 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Incorrect visibility change for OIDC_ISSUER_URL
The OIDC_ISSUER_URL export was changed from export const to const (private). However, line 16 constructs OIDC_DISCOVERY_URL from it, which IS exported. This means:
- The URL is still exposed indirectly via
OIDC_DISCOVERY_URL - Other parts of the codebase that may have imported
OIDC_ISSUER_URLdirectly will break
If the intention was to hide the issuer URL for security, this doesn't achieve that. If it was to consolidate usage, ensure no other files import OIDC_ISSUER_URL directly.
Kapture.2025-12-03.at.10.33.10.mp4
OIDC RP-Initiated Logout
Summary
Implements complete sign-out from the OIDC provider (Okta) in addition to local session cleanup. Previously, users were only signed out locally, leaving their SSO session active.
What Changed
end_session_endpointfrom the OIDC providerid_tokenis now stored in the encrypted OIDC cookie for use during logoutid_token_hintandpost_logout_redirect_uri/signinResult
Users are now fully signed out from Okta SSO. On next sign-in, they will be prompted for credentials again.
Tests
Added unit tests to verify the sign-out flow executes in the correct order and handles errors gracefully.