Skip to content

Commit 241ab13

Browse files
committed
feat: handle oidc signout
1 parent 57df80f commit 241ab13

File tree

6 files changed

+266
-55
lines changed

6 files changed

+266
-55
lines changed

src/app/catalog/page.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,7 @@
1-
import { headers } from "next/headers";
2-
import { redirect } from "next/navigation";
3-
import { auth } from "@/lib/auth/auth";
41
import { getServers } from "./actions";
52
import { ServersWrapper } from "./components/servers-wrapper";
63

74
export default async function CatalogPage() {
8-
const session = await auth.api.getSession({
9-
headers: await headers(),
10-
});
11-
12-
if (!session) {
13-
redirect("/signin");
14-
}
15-
165
const servers = await getServers();
176

187
return <ServersWrapper servers={servers} />;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { clearRecordedRequests, server } from "@/mocks/node";
3+
import * as actions from "../actions";
4+
5+
// Remove global mock of auth-client from vitest.setup.ts
6+
vi.unmock("@/lib/auth/auth-client");
7+
8+
// Hoist mocks
9+
const mockAuthClientSignOut = vi.hoisted(() => vi.fn());
10+
const mockLocationReplace = vi.hoisted(() => vi.fn());
11+
12+
// Mock Better Auth client
13+
vi.mock("better-auth/client/plugins", () => ({
14+
genericOAuthClient: vi.fn(() => ({})),
15+
}));
16+
17+
vi.mock("better-auth/react", () => ({
18+
createAuthClient: vi.fn(() => ({
19+
signIn: vi.fn(),
20+
useSession: vi.fn(),
21+
signOut: mockAuthClientSignOut,
22+
})),
23+
}));
24+
25+
// Mock window.location globally
26+
Object.defineProperty(globalThis, "window", {
27+
value: {
28+
location: {
29+
replace: mockLocationReplace,
30+
},
31+
},
32+
writable: true,
33+
configurable: true,
34+
});
35+
36+
describe("signOut", () => {
37+
beforeEach(() => {
38+
vi.clearAllMocks();
39+
clearRecordedRequests();
40+
});
41+
42+
afterEach(() => {
43+
vi.restoreAllMocks();
44+
server.resetHandlers();
45+
});
46+
47+
it("calls getOidcSignOutUrl, clearOidcTokenAction, redirect, and authClient.signOut", async () => {
48+
const oidcLogoutUrl = "https://okta.example.com/logout?id_token_hint=xxx";
49+
50+
// Spy on server actions
51+
const getOidcSignOutUrlSpy = vi
52+
.spyOn(actions, "getOidcSignOutUrl")
53+
.mockResolvedValue(oidcLogoutUrl);
54+
const clearOidcTokenActionSpy = vi
55+
.spyOn(actions, "clearOidcTokenAction")
56+
.mockResolvedValue(undefined);
57+
mockAuthClientSignOut.mockResolvedValue(undefined);
58+
59+
const { signOut } = await import("../auth-client");
60+
61+
await signOut();
62+
63+
// Verify all functions were called
64+
expect(getOidcSignOutUrlSpy).toHaveBeenCalledTimes(1);
65+
expect(clearOidcTokenActionSpy).toHaveBeenCalledTimes(1);
66+
expect(mockLocationReplace).toHaveBeenCalledWith(oidcLogoutUrl);
67+
expect(mockAuthClientSignOut).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it("calls functions in correct order", async () => {
71+
const callOrder: string[] = [];
72+
73+
vi.spyOn(actions, "getOidcSignOutUrl").mockImplementation(async () => {
74+
callOrder.push("getOidcSignOutUrl");
75+
return "https://okta.example.com/logout";
76+
});
77+
78+
vi.spyOn(actions, "clearOidcTokenAction").mockImplementation(async () => {
79+
callOrder.push("clearOidcTokenAction");
80+
});
81+
82+
mockLocationReplace.mockImplementation(() => {
83+
callOrder.push("window.location.replace");
84+
});
85+
86+
mockAuthClientSignOut.mockImplementation(async () => {
87+
callOrder.push("authClient.signOut");
88+
});
89+
90+
const { signOut } = await import("../auth-client");
91+
92+
await signOut();
93+
94+
expect(callOrder).toEqual([
95+
"getOidcSignOutUrl",
96+
"clearOidcTokenAction",
97+
"window.location.replace",
98+
"authClient.signOut",
99+
]);
100+
});
101+
102+
it("redirects to /signin on error", async () => {
103+
vi.spyOn(actions, "getOidcSignOutUrl").mockRejectedValue(
104+
new Error("Network error"),
105+
);
106+
107+
const { signOut } = await import("../auth-client");
108+
109+
await signOut();
110+
111+
expect(mockLocationReplace).toHaveBeenCalledWith("/signin");
112+
});
113+
114+
it("uses /signin as fallback when no OIDC URL", async () => {
115+
vi.spyOn(actions, "getOidcSignOutUrl").mockResolvedValue("/signin");
116+
vi.spyOn(actions, "clearOidcTokenAction").mockResolvedValue(undefined);
117+
mockAuthClientSignOut.mockResolvedValue(undefined);
118+
119+
const { signOut } = await import("../auth-client");
120+
121+
await signOut();
122+
123+
expect(mockLocationReplace).toHaveBeenCalledWith("/signin");
124+
});
125+
});

src/lib/auth/__tests__/auth.test.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,14 @@ describe("auth", () => {
8282

8383
it("should return null when token is expired", async () => {
8484
const expiredTokenData: OidcTokenData = {
85+
id: "expired-token-id",
86+
createdAt: new Date(),
87+
updatedAt: new Date(),
88+
providerId: "provider-id",
89+
accountId: "account-id",
8590
accessToken: "expired-token",
8691
userId: "user-123",
87-
expiresAt: Date.now() - 1000, // Expired 1 second ago
92+
accessTokenExpiresAt: Date.now() - 1000, // Expired 1 second ago
8893
};
8994

9095
const encryptedPayload = await encrypt(
@@ -102,9 +107,14 @@ describe("auth", () => {
102107

103108
it("should return null when token belongs to different user", async () => {
104109
const tokenData: OidcTokenData = {
110+
id: "valid-token-id",
111+
createdAt: new Date(),
112+
updatedAt: new Date(),
113+
providerId: "provider-id",
114+
accountId: "account-id",
105115
accessToken: "valid-token",
106116
userId: "user-456", // Different user
107-
expiresAt: Date.now() + 3600000,
117+
accessTokenExpiresAt: Date.now() + 3600000,
108118
};
109119

110120
const encryptedPayload = await encrypt(
@@ -120,9 +130,14 @@ describe("auth", () => {
120130

121131
it("should return access token when valid", async () => {
122132
const tokenData: OidcTokenData = {
133+
id: "valid-token-id",
134+
createdAt: new Date(),
135+
updatedAt: new Date(),
136+
providerId: "provider-id",
137+
accountId: "account-id",
123138
accessToken: "valid-access-token-123",
124139
userId: "user-123",
125-
expiresAt: Date.now() + 3600000, // Valid for 1 hour
140+
accessTokenExpiresAt: Date.now() + 3600000, // Valid for 1 hour
126141
};
127142

128143
const encryptedPayload = await encrypt(
@@ -138,7 +153,7 @@ describe("auth", () => {
138153

139154
it("should return null when token data is invalid", async () => {
140155
// Create invalid token data (missing required fields)
141-
const invalidData = { accessToken: "token" }; // Missing userId and expiresAt
156+
const invalidData = { accessToken: "token" }; // Missing userId and accessTokenExpiresAt
142157
const invalidPayload = await encrypt(
143158
invalidData as OidcTokenData,
144159
process.env.BETTER_AUTH_SECRET as string,
@@ -181,26 +196,36 @@ describe("auth", () => {
181196
describe("OidcTokenData Type Guard", () => {
182197
it("should validate correct OidcTokenData structure", () => {
183198
const validData: OidcTokenData = {
199+
id: "valid-token-id",
200+
createdAt: new Date(),
201+
updatedAt: new Date(),
202+
providerId: "provider-id",
203+
accountId: "account-id",
184204
accessToken: "token",
185205
userId: "user-123",
186-
expiresAt: Date.now() + 3600000,
206+
accessTokenExpiresAt: Date.now() + 3600000,
187207
refreshToken: "refresh-token",
188208
};
189209

190210
// Type guard is private, so we test indirectly through getOidcProviderAccessToken
191211
expect(validData).toHaveProperty("accessToken");
192212
expect(validData).toHaveProperty("userId");
193-
expect(validData).toHaveProperty("expiresAt");
213+
expect(validData).toHaveProperty("accessTokenExpiresAt");
194214
expect(typeof validData.accessToken).toBe("string");
195215
expect(typeof validData.userId).toBe("string");
196-
expect(typeof validData.expiresAt).toBe("number");
216+
expect(typeof validData.accessTokenExpiresAt).toBe("number");
197217
});
198218

199219
it("should handle optional refreshToken", () => {
200220
const dataWithoutRefresh: OidcTokenData = {
221+
id: "valid-token-id",
222+
createdAt: new Date(),
223+
updatedAt: new Date(),
224+
providerId: "provider-id",
225+
accountId: "account-id",
201226
accessToken: "token",
202227
userId: "user-123",
203-
expiresAt: Date.now() + 3600000,
228+
accessTokenExpiresAt: Date.now() + 3600000,
204229
};
205230

206231
expect(dataWithoutRefresh.refreshToken).toBeUndefined();

src/lib/auth/__tests__/token.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,14 @@ describe("token", () => {
7979
it("should return existing token if still valid", async () => {
8080
const userId = "user-123";
8181
const tokenData: OidcTokenData = {
82+
id: "valid-token-id",
83+
createdAt: new Date(),
84+
updatedAt: new Date(),
85+
providerId: "provider-id",
86+
accountId: "account-id",
8287
accessToken: "valid-access-token",
8388
userId,
84-
expiresAt: Date.now() + 3600000, // Valid for 1 hour
89+
accessTokenExpiresAt: Date.now() + 3600000, // Valid for 1 hour
8590
};
8691

8792
const encryptedPayload = await encrypt(
@@ -100,9 +105,14 @@ describe("token", () => {
100105
it("should refresh token if expired", async () => {
101106
const userId = "user-123";
102107
const expiredTokenData: OidcTokenData = {
108+
id: "expired-token-id",
109+
createdAt: new Date(),
110+
updatedAt: new Date(),
111+
providerId: "provider-id",
112+
accountId: "account-id",
103113
accessToken: "expired-access-token",
104114
userId,
105-
expiresAt: Date.now() - 1000, // Expired 1 second ago
115+
accessTokenExpiresAt: Date.now() - 1000, // Expired 1 second ago
106116
};
107117

108118
const encryptedPayload = await encrypt(
@@ -149,9 +159,14 @@ describe("token", () => {
149159
it("should return null if refresh API returns invalid response", async () => {
150160
const userId = "user-123";
151161
const expiredTokenData: OidcTokenData = {
162+
id: "expired-token-id",
163+
createdAt: new Date(),
164+
updatedAt: new Date(),
165+
providerId: "provider-id",
166+
accountId: "account-id",
152167
accessToken: "expired-token",
153168
userId,
154-
expiresAt: Date.now() - 1000,
169+
accessTokenExpiresAt: Date.now() - 1000,
155170
};
156171

157172
const encryptedPayload = await encrypt(
@@ -177,9 +192,14 @@ describe("token", () => {
177192
it("should handle network errors during refresh", async () => {
178193
const userId = "user-123";
179194
const expiredTokenData: OidcTokenData = {
195+
id: "expired-token-id",
196+
createdAt: new Date(),
197+
updatedAt: new Date(),
198+
providerId: "provider-id",
199+
accountId: "account-id",
180200
accessToken: "expired-token",
181201
userId,
182-
expiresAt: Date.now() - 1000,
202+
accessTokenExpiresAt: Date.now() - 1000,
183203
};
184204

185205
const encryptedPayload = await encrypt(
@@ -288,10 +308,15 @@ describe("token", () => {
288308
it("should handle complete refresh flow end-to-end", async () => {
289309
const userId = "user-123";
290310
const expiredTokenData: OidcTokenData = {
311+
id: "expired-token-id",
312+
createdAt: new Date(),
313+
updatedAt: new Date(),
314+
providerId: "provider-id",
315+
accountId: "account-id",
291316
accessToken: "expired-token",
292317
refreshToken: "valid-refresh-token",
293318
userId,
294-
expiresAt: Date.now() - 1000,
319+
accessTokenExpiresAt: Date.now() - 1000,
295320
refreshTokenExpiresAt: Date.now() + 86400000,
296321
};
297322

src/lib/auth/actions.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use server";
2+
3+
import { headers } from "next/headers";
4+
import {
5+
auth,
6+
clearOidcProviderToken,
7+
getOidcDiscovery,
8+
} from "@/lib/auth/auth";
9+
import { BASE_URL } from "@/lib/auth/constants";
10+
import { getOidcIdToken } from "@/lib/auth/utils";
11+
12+
/**
13+
* Server action to clear OIDC token cookie on sign out.
14+
*/
15+
export async function clearOidcTokenAction(): Promise<void> {
16+
await clearOidcProviderToken();
17+
}
18+
19+
/**
20+
* Server action to build the OIDC logout URL for RP-Initiated Logout.
21+
* Returns the OIDC provider's logout URL, or "/signin" as fallback.
22+
*/
23+
export async function getOidcSignOutUrl(): Promise<string> {
24+
try {
25+
const session = await auth.api.getSession({
26+
headers: await headers(),
27+
});
28+
29+
if (!session?.user?.id) {
30+
console.warn("[Auth] No active session for logout");
31+
return "/signin";
32+
}
33+
34+
const discovery = await getOidcDiscovery();
35+
36+
if (!discovery?.endSessionEndpoint) {
37+
console.error("[Auth] OIDC end_session_endpoint not available");
38+
return "/signin";
39+
}
40+
41+
const idToken = await getOidcIdToken(session.user.id);
42+
43+
if (!idToken) {
44+
console.warn("[Auth] No idToken found for OIDC logout");
45+
return "/signin";
46+
}
47+
48+
const url = new URL(discovery.endSessionEndpoint);
49+
url.searchParams.set("id_token_hint", idToken);
50+
url.searchParams.set("post_logout_redirect_uri", `${BASE_URL}/signin`);
51+
52+
return url.toString();
53+
} catch (error) {
54+
console.error("[Auth] Error building OIDC logout URL:", error);
55+
return "/signin";
56+
}
57+
}

0 commit comments

Comments
 (0)