From 8efdd2f63628556e5a1487373994bd98be64a5f9 Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 3 Dec 2025 10:30:16 +0100 Subject: [PATCH 1/7] feat: save idToken, refactor oidc account types --- src/app/api/auth/refresh-token/route.ts | 43 +++++++--- src/lib/auth/auth-actions.ts | 12 --- src/lib/auth/auth.ts | 108 +++++++++++++++--------- src/lib/auth/constants.ts | 5 +- src/lib/auth/types.ts | 48 +++++++++-- src/lib/auth/utils.ts | 65 +++++++++++--- 6 files changed, 197 insertions(+), 84 deletions(-) delete mode 100644 src/lib/auth/auth-actions.ts diff --git a/src/app/api/auth/refresh-token/route.ts b/src/app/api/auth/refresh-token/route.ts index bfe8dacc..588696f6 100644 --- a/src/app/api/auth/refresh-token/route.ts +++ b/src/app/api/auth/refresh-token/route.ts @@ -1,8 +1,11 @@ -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { refreshAccessToken } from "@/lib/auth/auth"; -import { BETTER_AUTH_SECRET, COOKIE_NAME } from "@/lib/auth/constants"; +import { auth, refreshAccessToken } from "@/lib/auth/auth"; +import { + BETTER_AUTH_SECRET, + OIDC_TOKEN_COOKIE_NAME, +} from "@/lib/auth/constants"; import type { OidcTokenData } from "@/lib/auth/types"; import { decrypt } from "@/lib/auth/utils"; @@ -13,6 +16,16 @@ import { decrypt } from "@/lib/auth/utils"; */ export async function POST(request: NextRequest) { try { + // Check if Better Auth session exists before attempting token refresh + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + // No active session - skip token refresh (user is logged out) + return NextResponse.json({ error: "No active session" }, { status: 401 }); + } + const body = await request.json(); const { userId } = body; @@ -20,8 +33,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Missing userId" }, { status: 400 }); } + // Verify userId matches the session + if (userId !== session.user.id) { + return NextResponse.json({ error: "User ID mismatch" }, { status: 401 }); + } + const cookieStore = await cookies(); - const encryptedCookie = cookieStore.get(COOKIE_NAME); + const encryptedCookie = cookieStore.get(OIDC_TOKEN_COOKIE_NAME); if (!encryptedCookie?.value) { return NextResponse.json({ error: "No token found" }, { status: 401 }); @@ -32,32 +50,33 @@ export async function POST(request: NextRequest) { tokenData = await decrypt(encryptedCookie.value, BETTER_AUTH_SECRET); } catch (error) { console.error("[Refresh API] Token decryption failed:", error); - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(OIDC_TOKEN_COOKIE_NAME); return NextResponse.json({ error: "Invalid token" }, { status: 401 }); } if (tokenData.userId !== userId) { console.error("[Refresh API] Token userId mismatch"); - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(OIDC_TOKEN_COOKIE_NAME); return NextResponse.json({ error: "Invalid token" }, { status: 401 }); } if (!tokenData.refreshToken) { console.error("[Refresh API] No refresh token available"); - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(OIDC_TOKEN_COOKIE_NAME); return NextResponse.json({ error: "No refresh token" }, { status: 401 }); } // Call refreshAccessToken which will save the new token in the cookie - const refreshedData = await refreshAccessToken( - tokenData.refreshToken, + const refreshedData = await refreshAccessToken({ + refreshToken: tokenData.refreshToken, + refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt, userId, - tokenData.refreshTokenExpiresAt, - ); + idToken: tokenData.idToken, + }); if (!refreshedData) { console.error("[Refresh API] Token refresh failed"); - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(OIDC_TOKEN_COOKIE_NAME); return NextResponse.json( { error: "[Refresh API] Refresh failed" }, { status: 401 }, diff --git a/src/lib/auth/auth-actions.ts b/src/lib/auth/auth-actions.ts deleted file mode 100644 index 8bd03320..00000000 --- a/src/lib/auth/auth-actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -"use server"; - -import { clearOidcProviderToken } from "@/lib/auth/auth"; - -/** - * Server action to clear OIDC token cookie on sign out. - * Called by client-side signOut function. - * Throws an error if it fails. - */ -export async function clearOidcTokenAction(): Promise { - await clearOidcProviderToken(); -} diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 59b52b90..26fc07e8 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -1,27 +1,32 @@ -import type { Auth, BetterAuthOptions } from "better-auth"; -import { betterAuth } from "better-auth"; +import { type Auth, type BetterAuthOptions, betterAuth } from "better-auth"; import { genericOAuth } from "better-auth/plugins"; import { cookies } from "next/headers"; import { BASE_URL, BETTER_AUTH_SECRET, - COOKIE_NAME, IS_PRODUCTION, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, - OIDC_ISSUER_URL, + OIDC_DISCOVERY_URL, OIDC_PROVIDER_ID, OIDC_SCOPES, + OIDC_TOKEN_COOKIE_NAME, TOKEN_SEVEN_DAYS_SECONDS, TRUSTED_ORIGINS, } from "./constants"; -import type { OIDCDiscovery, OidcTokenData, TokenResponse } from "./types"; +import type { + OidcDiscovery, + OidcDiscoveryResponse, + OidcTokenData, + TokenResponse, +} from "./types"; import { decrypt, encrypt, saveAccountToken } from "./utils"; /** * Cached token endpoint to avoid repeated discovery calls. */ let cachedTokenEndpoint: string | null = null; +let cachedEndSessionEndpoint: string | null = null; /** * Saves encrypted token data in HTTP-only cookie. @@ -31,7 +36,7 @@ export async function saveTokenCookie(tokenData: OidcTokenData): Promise { const encrypted = await encrypt(tokenData, BETTER_AUTH_SECRET); const cookieStore = await cookies(); - cookieStore.set(COOKIE_NAME, encrypted, { + cookieStore.set(OIDC_TOKEN_COOKIE_NAME, encrypted, { httpOnly: true, secure: IS_PRODUCTION, sameSite: "lax", @@ -41,16 +46,19 @@ export async function saveTokenCookie(tokenData: OidcTokenData): Promise { } /** - * Discovers and caches the token endpoint from OIDC provider. + * Discovers and caches the token and end_session endpoints from OIDC provider. + * Exported for use in server actions and token refresh logic. */ -async function getTokenEndpoint(): Promise { +export async function getOidcDiscovery(): Promise { if (cachedTokenEndpoint) { - return cachedTokenEndpoint; + return { + tokenEndpoint: cachedTokenEndpoint, + endSessionEndpoint: cachedEndSessionEndpoint, + }; } try { - const discoveryUrl = `${OIDC_ISSUER_URL}/.well-known/openid-configuration`; - const response = await fetch(discoveryUrl); + const response = await fetch(OIDC_DISCOVERY_URL); if (!response.ok) { console.error( @@ -60,10 +68,14 @@ async function getTokenEndpoint(): Promise { return null; } - const discovery = (await response.json()) as OIDCDiscovery; + const discovery = (await response.json()) as OidcDiscovery; cachedTokenEndpoint = discovery.token_endpoint; + cachedEndSessionEndpoint = discovery.end_session_endpoint; - return cachedTokenEndpoint; + return { + tokenEndpoint: cachedTokenEndpoint, + endSessionEndpoint: cachedEndSessionEndpoint, + }; } catch (error) { console.error("[Auth] Error fetching OIDC discovery document:", error); return null; @@ -74,24 +86,34 @@ async function getTokenEndpoint(): Promise { * Attempts to refresh the access token using the refresh token. * Returns new token data if successful, null otherwise. */ -export async function refreshAccessToken( - refreshToken: string, - userId: string, - refreshTokenExpiresAt?: number, -): Promise { - if (!refreshToken || !userId) { - console.error("[Auth] Missing refresh token or userId"); - return null; - } +export async function refreshAccessToken({ + refreshToken, + userId, + idToken: initialIdToken, + refreshTokenExpiresAt: initialRefreshTokenExpiresAt, +}: { + refreshToken: string; + userId: string; + refreshTokenExpiresAt?: number | undefined | null; + idToken?: string | undefined | null; +}): Promise { + try { + if ( + initialRefreshTokenExpiresAt && + initialRefreshTokenExpiresAt <= Date.now() + ) { + console.error("[Auth] Refresh token expired"); + return null; + } - // Check if refresh token is expired before attempting to refresh - if (refreshTokenExpiresAt && refreshTokenExpiresAt <= Date.now()) { - console.error("[Auth] Refresh token expired"); - return null; - } + const discovery = await getOidcDiscovery(); - try { - const tokenEndpoint = await getTokenEndpoint(); + if (!discovery) { + console.error("[Auth] OIDC discovery not available"); + return null; + } + + const { tokenEndpoint } = discovery; if (!tokenEndpoint) { console.error("[Auth] Token endpoint not available"); @@ -122,13 +144,13 @@ export async function refreshAccessToken( return null; } - const tokenResponse = (await response.json()) as TokenResponse; + const tokenResponse: TokenResponse = await response.json(); - const expiresAt = Date.now() + tokenResponse.expires_in * 1000; + const accessTokenExpiresAt = Date.now() + tokenResponse.expires_in * 1000; const refreshTokenExpiresAt = tokenResponse.refresh_expires_in ? Date.now() + tokenResponse.refresh_expires_in * 1000 : undefined; - + const idToken = tokenResponse.id_token ?? initialIdToken; const newRefreshToken = tokenResponse.refresh_token || refreshToken; if (!tokenResponse.refresh_token) { console.warn( @@ -139,9 +161,10 @@ export async function refreshAccessToken( const newTokenData: OidcTokenData = { accessToken: tokenResponse.access_token, refreshToken: newRefreshToken, - expiresAt, + accessTokenExpiresAt, refreshTokenExpiresAt, userId, + idToken, }; // Save the new token data in the cookie @@ -156,12 +179,18 @@ export async function refreshAccessToken( } export const auth: Auth = betterAuth({ + debug: true, secret: BETTER_AUTH_SECRET, baseURL: BASE_URL, + account: { + storeStateStrategy: "cookie", + storeAccountCookie: true, + }, trustedOrigins: TRUSTED_ORIGINS, session: { cookieCache: { enabled: true, + strategy: "jwe", maxAge: TOKEN_SEVEN_DAYS_SECONDS, // 7 days - match session duration! }, // Session duration should match or exceed refresh token lifetime @@ -174,7 +203,7 @@ export const auth: Auth = betterAuth({ config: [ { providerId: OIDC_PROVIDER_ID, - discoveryUrl: `${OIDC_ISSUER_URL}/.well-known/openid-configuration`, + discoveryUrl: OIDC_DISCOVERY_URL, redirectURI: `${BASE_URL}/api/auth/oauth2/callback/${OIDC_PROVIDER_ID}`, clientId: OIDC_CLIENT_ID, clientSecret: OIDC_CLIENT_SECRET, @@ -206,7 +235,7 @@ export async function getOidcProviderAccessToken( ): Promise { try { const cookieStore = await cookies(); - const encryptedCookie = cookieStore.get(COOKIE_NAME); + const encryptedCookie = cookieStore.get(OIDC_TOKEN_COOKIE_NAME); if (!encryptedCookie?.value) { return null; @@ -227,11 +256,14 @@ export async function getOidcProviderAccessToken( const now = Date.now(); - if (tokenData.expiresAt <= now) { + if ( + tokenData.accessTokenExpiresAt && + tokenData.accessTokenExpiresAt <= now + ) { return null; } - return tokenData.accessToken; + return tokenData.accessToken || null; } catch (error) { console.error("[Auth] Unexpected error reading OIDC token:", error); return null; @@ -243,5 +275,5 @@ export async function getOidcProviderAccessToken( */ export async function clearOidcProviderToken(): Promise { const cookieStore = await cookies(); - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(OIDC_TOKEN_COOKIE_NAME); } diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts index 2a820f4d..fa8f686c 100644 --- a/src/lib/auth/constants.ts +++ b/src/lib/auth/constants.ts @@ -8,11 +8,12 @@ * Server-side only - not exposed to the client. */ export const OIDC_PROVIDER_ID = process.env.OIDC_PROVIDER_ID || "oidc"; -export const OIDC_ISSUER_URL = process.env.OIDC_ISSUER_URL || ""; +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`; export const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET || "build-time-better-auth-secret"; export const OIDC_SCOPES = process.env.OIDC_SCOPES?.split(",") ?? [ @@ -27,7 +28,7 @@ export const TOKEN_ONE_HOUR_MS = 60 * 60 * 1000; // 3,600,000 ms (1 hour) export const TOKEN_SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; // 604,800 seconds (7 days) // Cookie configuration -export const COOKIE_NAME = "oidc_token" as const; +export const OIDC_TOKEN_COOKIE_NAME = "oidc_token" as const; // Trusted origins for Better Auth const trustedOriginsFromEnv = process.env.TRUSTED_ORIGINS diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts index 12263d8f..8f2a1056 100644 --- a/src/lib/auth/types.ts +++ b/src/lib/auth/types.ts @@ -2,23 +2,53 @@ * Authentication types and interfaces for OIDC token management. */ +import type { Account } from "better-auth"; + /** * Represents the data stored in the encrypted OIDC token cookie. */ -export interface OidcTokenData { - accessToken: string; - refreshToken?: string; - expiresAt: number; +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; } /** * OIDC Discovery Document structure. * Retrieved from /.well-known/openid-configuration endpoint. */ -export interface OIDCDiscovery { +export interface OidcDiscovery { token_endpoint: string; + end_session_endpoint: string; + issuer: string; + authorization_endpoint: string; + userinfo_endpoint: string; + registration_endpoint: string; + jwks_uri: string; + response_types_supported: string[]; + response_modes_supported: string[]; + grant_types_supported: string[]; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + scopes_supported: string[]; + claims_supported: string[]; + code_challenge_methods_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + request_object_signing_alg_values_supported: string[]; [key: string]: unknown; } @@ -32,4 +62,10 @@ export interface TokenResponse { expires_in: number; refresh_expires_in?: number; token_type: string; + id_token?: string; +} + +export interface OidcDiscoveryResponse { + tokenEndpoint: string | null; + endSessionEndpoint: string | null; } diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index fdaa521d..e192138b 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -3,8 +3,15 @@ */ import { createHash } from "node:crypto"; +import type { Account } from "better-auth"; import * as jose from "jose"; -import { TOKEN_ONE_HOUR_MS } from "./constants"; +import { cookies } from "next/headers"; +import { saveTokenCookie } from "./auth"; +import { + BETTER_AUTH_SECRET, + OIDC_TOKEN_COOKIE_NAME, + TOKEN_ONE_HOUR_MS, +} from "./constants"; import type { OidcTokenData } from "./types"; /** @@ -68,6 +75,7 @@ export async function decrypt( /** * Type guard to validate OidcTokenData structure at runtime. * Used after decrypting token data from cookie to ensure data integrity. + * Note: idToken is not validated here as it's optional and not critical for token validation. */ export function isOidcTokenData(data: unknown): data is OidcTokenData { if (typeof data !== "object" || data === null) { @@ -78,7 +86,7 @@ export function isOidcTokenData(data: unknown): data is OidcTokenData { 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 || @@ -86,21 +94,49 @@ export function isOidcTokenData(data: unknown): data is OidcTokenData { ); } +/** + * Retrieves the OIDC ID token from HTTP-only cookie. + * Returns null if token not found or belongs to different user. + * Used for OIDC logout (RP-Initiated Logout). + */ +export async function getOidcIdToken(userId: string): Promise { + try { + const cookieStore = await cookies(); + const encryptedCookie = cookieStore.get(OIDC_TOKEN_COOKIE_NAME); + + if (!encryptedCookie?.value) { + return null; + } + + let tokenData: OidcTokenData; + try { + tokenData = await decrypt(encryptedCookie.value, BETTER_AUTH_SECRET); + } catch (error) { + console.error("[Auth] Token decryption failed:", error); + return null; + } + + if (tokenData.userId !== userId) { + console.error("[Auth] Token userId mismatch"); + return null; + } + + return tokenData.idToken || null; + } catch (error) { + console.error("[Auth] Unexpected error reading OIDC ID token:", error); + return null; + } +} + /** * Saves OIDC tokens from account creation or update into HTTP-only cookie. * Used by Better Auth database hooks for both initial login and re-login. * * @param account - Account data from Better Auth containing OIDC tokens */ -export async function saveAccountToken(account: { - accessToken?: string | null; - refreshToken?: string | null; - accessTokenExpiresAt?: Date | string | null; - refreshTokenExpiresAt?: Date | string | null; - userId: string; -}) { +export async function saveAccountToken(account: Account) { if (account.accessToken && account.userId) { - const expiresAt = account.accessTokenExpiresAt + const accessTokenExpiresAt = account.accessTokenExpiresAt ? new Date(account.accessTokenExpiresAt).getTime() : Date.now() + TOKEN_ONE_HOUR_MS; @@ -109,24 +145,25 @@ export async function saveAccountToken(account: { : undefined; const tokenData: OidcTokenData = { + ...account, accessToken: account.accessToken, refreshToken: account.refreshToken || undefined, - expiresAt, + accessTokenExpiresAt, refreshTokenExpiresAt, userId: account.userId, }; + console.log("[account] Token data to save:", JSON.stringify(account)); + console.log("[Save Token] Token data to save:", { hasAccessToken: !!tokenData.accessToken, hasRefreshToken: !!tokenData.refreshToken, - expiresAt: new Date(tokenData.expiresAt).toISOString(), + accessTokenExpiresAt: new Date(accessTokenExpiresAt).toISOString(), refreshTokenExpiresAt: tokenData.refreshTokenExpiresAt ? new Date(tokenData.refreshTokenExpiresAt).toISOString() : "none", }); - // Dynamic import to avoid circular dependency - const { saveTokenCookie } = await import("./auth"); await saveTokenCookie(tokenData); console.log("[Save Token] Token cookie saved successfully"); From e94dd6db1040e367150fa472b164fb59a975c29c Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 3 Dec 2025 10:31:09 +0100 Subject: [PATCH 2/7] feat: handle oidc signout --- src/app/catalog/page.tsx | 11 -- src/lib/auth/__tests__/auth-client.test.ts | 125 +++++++++++++++++++++ src/lib/auth/__tests__/auth.test.ts | 41 +++++-- src/lib/auth/__tests__/token.test.ts | 35 +++++- src/lib/auth/actions.ts | 57 ++++++++++ src/lib/auth/auth-client.ts | 52 ++++----- 6 files changed, 266 insertions(+), 55 deletions(-) create mode 100644 src/lib/auth/__tests__/auth-client.test.ts create mode 100644 src/lib/auth/actions.ts diff --git a/src/app/catalog/page.tsx b/src/app/catalog/page.tsx index dd17ff61..e623747c 100644 --- a/src/app/catalog/page.tsx +++ b/src/app/catalog/page.tsx @@ -1,18 +1,7 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -import { auth } from "@/lib/auth/auth"; import { getServers } from "./actions"; import { ServersWrapper } from "./components/servers-wrapper"; export default async function CatalogPage() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - redirect("/signin"); - } - const servers = await getServers(); return ; diff --git a/src/lib/auth/__tests__/auth-client.test.ts b/src/lib/auth/__tests__/auth-client.test.ts new file mode 100644 index 00000000..eea9701f --- /dev/null +++ b/src/lib/auth/__tests__/auth-client.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearRecordedRequests, server } from "@/mocks/node"; +import * as actions from "../actions"; + +// Remove global mock of auth-client from vitest.setup.ts +vi.unmock("@/lib/auth/auth-client"); + +// Hoist mocks +const mockAuthClientSignOut = vi.hoisted(() => vi.fn()); +const mockLocationReplace = vi.hoisted(() => vi.fn()); + +// Mock Better Auth client +vi.mock("better-auth/client/plugins", () => ({ + genericOAuthClient: vi.fn(() => ({})), +})); + +vi.mock("better-auth/react", () => ({ + createAuthClient: vi.fn(() => ({ + signIn: vi.fn(), + useSession: vi.fn(), + signOut: mockAuthClientSignOut, + })), +})); + +// Mock window.location globally +Object.defineProperty(globalThis, "window", { + value: { + location: { + replace: mockLocationReplace, + }, + }, + writable: true, + configurable: true, +}); + +describe("signOut", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearRecordedRequests(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + server.resetHandlers(); + }); + + it("calls getOidcSignOutUrl, clearOidcTokenAction, redirect, and authClient.signOut", async () => { + const oidcLogoutUrl = "https://okta.example.com/logout?id_token_hint=xxx"; + + // Spy on server actions + const getOidcSignOutUrlSpy = vi + .spyOn(actions, "getOidcSignOutUrl") + .mockResolvedValue(oidcLogoutUrl); + const clearOidcTokenActionSpy = vi + .spyOn(actions, "clearOidcTokenAction") + .mockResolvedValue(undefined); + mockAuthClientSignOut.mockResolvedValue(undefined); + + const { signOut } = await import("../auth-client"); + + await signOut(); + + // Verify all functions were called + expect(getOidcSignOutUrlSpy).toHaveBeenCalledTimes(1); + expect(clearOidcTokenActionSpy).toHaveBeenCalledTimes(1); + expect(mockLocationReplace).toHaveBeenCalledWith(oidcLogoutUrl); + expect(mockAuthClientSignOut).toHaveBeenCalledTimes(1); + }); + + it("calls functions in correct order", async () => { + const callOrder: string[] = []; + + vi.spyOn(actions, "getOidcSignOutUrl").mockImplementation(async () => { + callOrder.push("getOidcSignOutUrl"); + return "https://okta.example.com/logout"; + }); + + vi.spyOn(actions, "clearOidcTokenAction").mockImplementation(async () => { + callOrder.push("clearOidcTokenAction"); + }); + + mockLocationReplace.mockImplementation(() => { + callOrder.push("window.location.replace"); + }); + + mockAuthClientSignOut.mockImplementation(async () => { + callOrder.push("authClient.signOut"); + }); + + const { signOut } = await import("../auth-client"); + + await signOut(); + + expect(callOrder).toEqual([ + "getOidcSignOutUrl", + "clearOidcTokenAction", + "window.location.replace", + "authClient.signOut", + ]); + }); + + it("redirects to /signin on error", async () => { + vi.spyOn(actions, "getOidcSignOutUrl").mockRejectedValue( + new Error("Network error"), + ); + + const { signOut } = await import("../auth-client"); + + await signOut(); + + expect(mockLocationReplace).toHaveBeenCalledWith("/signin"); + }); + + it("uses /signin as fallback when no OIDC URL", async () => { + vi.spyOn(actions, "getOidcSignOutUrl").mockResolvedValue("/signin"); + vi.spyOn(actions, "clearOidcTokenAction").mockResolvedValue(undefined); + mockAuthClientSignOut.mockResolvedValue(undefined); + + const { signOut } = await import("../auth-client"); + + await signOut(); + + expect(mockLocationReplace).toHaveBeenCalledWith("/signin"); + }); +}); diff --git a/src/lib/auth/__tests__/auth.test.ts b/src/lib/auth/__tests__/auth.test.ts index 2752be7a..4ac3d3e2 100644 --- a/src/lib/auth/__tests__/auth.test.ts +++ b/src/lib/auth/__tests__/auth.test.ts @@ -82,9 +82,14 @@ describe("auth", () => { it("should return null when token is expired", async () => { const expiredTokenData: OidcTokenData = { + id: "expired-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "expired-token", userId: "user-123", - expiresAt: Date.now() - 1000, // Expired 1 second ago + accessTokenExpiresAt: Date.now() - 1000, // Expired 1 second ago }; const encryptedPayload = await encrypt( @@ -102,9 +107,14 @@ describe("auth", () => { it("should return null when token belongs to different user", async () => { const tokenData: OidcTokenData = { + id: "valid-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "valid-token", userId: "user-456", // Different user - expiresAt: Date.now() + 3600000, + accessTokenExpiresAt: Date.now() + 3600000, }; const encryptedPayload = await encrypt( @@ -120,9 +130,14 @@ describe("auth", () => { it("should return access token when valid", async () => { const tokenData: OidcTokenData = { + id: "valid-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "valid-access-token-123", userId: "user-123", - expiresAt: Date.now() + 3600000, // Valid for 1 hour + accessTokenExpiresAt: Date.now() + 3600000, // Valid for 1 hour }; const encryptedPayload = await encrypt( @@ -138,7 +153,7 @@ describe("auth", () => { it("should return null when token data is invalid", async () => { // Create invalid token data (missing required fields) - const invalidData = { accessToken: "token" }; // Missing userId and expiresAt + const invalidData = { accessToken: "token" }; // Missing userId and accessTokenExpiresAt const invalidPayload = await encrypt( invalidData as OidcTokenData, process.env.BETTER_AUTH_SECRET as string, @@ -181,26 +196,36 @@ describe("auth", () => { describe("OidcTokenData Type Guard", () => { it("should validate correct OidcTokenData structure", () => { const validData: OidcTokenData = { + id: "valid-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "token", userId: "user-123", - expiresAt: Date.now() + 3600000, + accessTokenExpiresAt: Date.now() + 3600000, refreshToken: "refresh-token", }; // Type guard is private, so we test indirectly through getOidcProviderAccessToken expect(validData).toHaveProperty("accessToken"); expect(validData).toHaveProperty("userId"); - expect(validData).toHaveProperty("expiresAt"); + expect(validData).toHaveProperty("accessTokenExpiresAt"); expect(typeof validData.accessToken).toBe("string"); expect(typeof validData.userId).toBe("string"); - expect(typeof validData.expiresAt).toBe("number"); + expect(typeof validData.accessTokenExpiresAt).toBe("number"); }); it("should handle optional refreshToken", () => { const dataWithoutRefresh: OidcTokenData = { + id: "valid-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "token", userId: "user-123", - expiresAt: Date.now() + 3600000, + accessTokenExpiresAt: Date.now() + 3600000, }; expect(dataWithoutRefresh.refreshToken).toBeUndefined(); diff --git a/src/lib/auth/__tests__/token.test.ts b/src/lib/auth/__tests__/token.test.ts index 3d649549..aa7f73cf 100644 --- a/src/lib/auth/__tests__/token.test.ts +++ b/src/lib/auth/__tests__/token.test.ts @@ -84,9 +84,14 @@ describe("token", () => { it("should return existing token if still valid", async () => { const userId = "user-123"; const tokenData: OidcTokenData = { + id: "valid-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "valid-access-token", userId, - expiresAt: Date.now() + 3600000, // Valid for 1 hour + accessTokenExpiresAt: Date.now() + 3600000, // Valid for 1 hour }; const encryptedPayload = await encrypt( @@ -105,9 +110,14 @@ describe("token", () => { it("should refresh token if expired", async () => { const userId = "user-123"; const expiredTokenData: OidcTokenData = { + id: "expired-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "expired-access-token", userId, - expiresAt: Date.now() - 1000, // Expired 1 second ago + accessTokenExpiresAt: Date.now() - 1000, // Expired 1 second ago }; const encryptedPayload = await encrypt( @@ -154,9 +164,14 @@ describe("token", () => { it("should return null if refresh API returns invalid response", async () => { const userId = "user-123"; const expiredTokenData: OidcTokenData = { + id: "expired-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "expired-token", userId, - expiresAt: Date.now() - 1000, + accessTokenExpiresAt: Date.now() - 1000, }; const encryptedPayload = await encrypt( @@ -182,9 +197,14 @@ describe("token", () => { it("should handle network errors during refresh", async () => { const userId = "user-123"; const expiredTokenData: OidcTokenData = { + id: "expired-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "expired-token", userId, - expiresAt: Date.now() - 1000, + accessTokenExpiresAt: Date.now() - 1000, }; const encryptedPayload = await encrypt( @@ -293,10 +313,15 @@ describe("token", () => { it("should handle complete refresh flow end-to-end", async () => { const userId = "user-123"; const expiredTokenData: OidcTokenData = { + id: "expired-token-id", + createdAt: new Date(), + updatedAt: new Date(), + providerId: "provider-id", + accountId: "account-id", accessToken: "expired-token", refreshToken: "valid-refresh-token", userId, - expiresAt: Date.now() - 1000, + accessTokenExpiresAt: Date.now() - 1000, refreshTokenExpiresAt: Date.now() + 86400000, }; diff --git a/src/lib/auth/actions.ts b/src/lib/auth/actions.ts new file mode 100644 index 00000000..4f98b089 --- /dev/null +++ b/src/lib/auth/actions.ts @@ -0,0 +1,57 @@ +"use server"; + +import { headers } from "next/headers"; +import { + auth, + clearOidcProviderToken, + getOidcDiscovery, +} from "@/lib/auth/auth"; +import { BASE_URL } from "@/lib/auth/constants"; +import { getOidcIdToken } from "@/lib/auth/utils"; + +/** + * Server action to clear OIDC token cookie on sign out. + */ +export async function clearOidcTokenAction(): Promise { + await clearOidcProviderToken(); +} + +/** + * Server action to build the OIDC logout URL for RP-Initiated Logout. + * Returns the OIDC provider's logout URL, or "/signin" as fallback. + */ +export async function getOidcSignOutUrl(): Promise { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + console.warn("[Auth] No active session for logout"); + return "/signin"; + } + + const discovery = await getOidcDiscovery(); + + if (!discovery?.endSessionEndpoint) { + console.error("[Auth] OIDC end_session_endpoint not available"); + return "/signin"; + } + + const idToken = await getOidcIdToken(session.user.id); + + if (!idToken) { + console.warn("[Auth] No idToken found for OIDC logout"); + return "/signin"; + } + + const url = new URL(discovery.endSessionEndpoint); + url.searchParams.set("id_token_hint", idToken); + url.searchParams.set("post_logout_redirect_uri", `${BASE_URL}/signin`); + + return url.toString(); + } catch (error) { + console.error("[Auth] Error building OIDC logout URL:", error); + return "/signin"; + } +} diff --git a/src/lib/auth/auth-client.ts b/src/lib/auth/auth-client.ts index a86221ce..f9ea20f3 100644 --- a/src/lib/auth/auth-client.ts +++ b/src/lib/auth/auth-client.ts @@ -1,7 +1,9 @@ +"use client"; + import { genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { toast } from "sonner"; -import { clearOidcTokenAction } from "./auth-actions"; +import { clearOidcTokenAction, getOidcSignOutUrl } from "./actions"; export const authClient = createAuthClient({ // Don't specify baseURL - it will use the same origin as the page @@ -11,42 +13,30 @@ export const authClient = createAuthClient({ export const { signIn, useSession } = authClient; -export const signOut = async (options?: { redirectTo?: string }) => { - const redirectUri = options?.redirectTo || "/signin"; - +/** + * Signs out the user from both the local session and OIDC provider + * Performs RP-Initiated Logout to terminate the SSO session at the provider + */ +export const signOut = async () => { try { - // Note: This does NOT logout from Okta SSO session - // User will be automatically re-authenticated on next signin (SSO behavior) - await authClient.signOut({ - fetchOptions: { - onSuccess: async () => { - // Clear OIDC token cookie after successful sign out - try { - await clearOidcTokenAction(); - } catch (error) { - console.warn("[Auth] Failed to clear OIDC token:", error); - // Continue with redirect even if cookie cleanup fails - } - window.location.href = redirectUri; - }, - onError: (ctx) => { - console.error("[Auth] Better Auth sign out error:", ctx.error); - toast.error("Sign out failed", { - description: - ctx.error.message || "An error occurred during sign out", - }); - // Still redirect even if there's an error - window.location.href = redirectUri; - }, - }, - }); + // 1. Get logout URL FIRST (while session still exists) + const redirectUrl = await getOidcSignOutUrl(); + + // 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(); } catch (error) { console.error("[Auth] Sign out error:", error); toast.error("Sign out failed", { description: error instanceof Error ? error.message : "An unexpected error occurred", }); - // Still redirect even if there's an error - window.location.href = redirectUri; + + // Fallback redirect on error + window.location.replace("/signin"); } }; From 5391dc8e8e3c4515c9f445d25d1c92331e1885cd Mon Sep 17 00:00:00 2001 From: Giuseppe Scuglia Date: Wed, 3 Dec 2025 10:33:55 +0100 Subject: [PATCH 3/7] fix: add cursor pointer to signin btn --- src/app/signin/signin-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/signin/signin-button.tsx b/src/app/signin/signin-button.tsx index 4f918000..3ce217ca 100644 --- a/src/app/signin/signin-button.tsx +++ b/src/app/signin/signin-button.tsx @@ -42,7 +42,7 @@ export function SignInButton({ providerId }: { providerId: string }) { return (