@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
207 lines (206 loc) • 10.6 kB
JavaScript
/* eslint-disable @typescript-eslint/no-unused-vars */
import { NextRequest } from "next/server";
import * as oauth from "oauth4webapi";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { InvalidStateError, MissingStateError } from "../errors";
import { AuthClient } from "./auth-client";
import { AbstractSessionStore } from "./session/abstract-session-store";
import { TransactionStore } from "./transaction-store";
vi.mock("./transaction-store");
vi.mock("oauth4webapi");
vi.mock("jose");
const MockTransactionStore = TransactionStore;
class TestSessionStore extends AbstractSessionStore {
constructor(config) {
super(config);
}
async get(_reqCookies) {
return null;
}
async set(_reqCookies, _resCookies, _session, _isNew) { }
async delete(_reqCookies, _resCookies) { }
}
const baseOptions = {
domain: "test.auth0.com",
clientId: "test-client-id",
clientSecret: "test-client-secret",
appBaseUrl: "http://localhost:3000",
secret: "a-sufficiently-long-secret-for-testing",
routes: {
login: "/api/auth/login",
logout: "/api/auth/logout",
callback: "/api/auth/callback"
}
};
describe("Ensure that redundant transaction cookies are deleted from auth-client methods", () => {
let authClient;
let mockTransactionStoreInstance;
let mockSessionStoreInstance;
beforeEach(async () => {
vi.clearAllMocks();
mockTransactionStoreInstance = new MockTransactionStore({
secret: "a-sufficiently-long-secret-for-testing"
});
const testSessionStoreOptions = {
secret: "test-secret",
cookieOptions: { name: "__session", path: "/", sameSite: "lax" }
};
mockSessionStoreInstance = new TestSessionStore(testSessionStoreOptions);
mockTransactionStoreInstance.getCookiePrefix = vi
.fn()
.mockReturnValue("__txn_");
mockTransactionStoreInstance.delete = vi.fn().mockResolvedValue(undefined);
mockTransactionStoreInstance.deleteAll = vi
.fn()
.mockResolvedValue(undefined);
mockTransactionStoreInstance.get = vi.fn().mockResolvedValue({
payload: {
state: "test-state",
nonce: "test-nonce",
codeVerifier: "cv",
responseType: "code",
returnTo: "/"
}
});
mockSessionStoreInstance.get = vi.fn().mockResolvedValue({
user: { sub: "user123" },
internal: { sid: "sid123" },
tokenSet: { idToken: "idtoken123" }
});
mockSessionStoreInstance.delete = vi.fn().mockResolvedValue(undefined);
mockSessionStoreInstance.set = vi.fn().mockResolvedValue(undefined);
authClient = new AuthClient({
...baseOptions,
sessionStore: mockSessionStoreInstance,
transactionStore: mockTransactionStoreInstance
});
authClient.discoverAuthorizationServerMetadata = vi
.fn()
.mockResolvedValue([
null,
{
issuer: "https://test.auth0.com/",
authorization_endpoint: "https://test.auth0.com/authorize",
token_endpoint: "https://test.auth0.com/oauth/token",
jwks_uri: "https://test.auth0.com/.well-known/jwks.json",
end_session_endpoint: "https://test.auth0.com/v2/logout" // Mock RP-Initiated Logout endpoint
}
]);
vi.spyOn(oauth, "validateAuthResponse").mockReturnValue(new URLSearchParams("code=auth_code"));
vi.spyOn(oauth, "authorizationCodeGrantRequest").mockResolvedValue(new Response());
vi.spyOn(oauth, "processAuthorizationCodeResponse").mockResolvedValue({
token_type: "Bearer",
access_token: "access_token_123",
id_token: "id_token_456",
refresh_token: "refresh_token_789",
expires_in: 3600,
scope: "openid profile email"
});
const clientId = baseOptions.clientId ?? "test-client-id";
vi.spyOn(oauth, "getValidatedIdTokenClaims").mockReturnValue({
sub: "user123",
sid: "sid123",
nonce: "test-nonce",
aud: clientId,
iss: `https://${baseOptions.domain}/`,
iat: Math.floor(Date.now() / 1000) - 60,
exp: Math.floor(Date.now() / 1000) + 3600
});
});
describe("handleLogout", () => {
it("should delete session cookie but no transaction cookies if none exist", async () => {
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith(req.cookies, res.cookies);
expect(res.status).toBeGreaterThanOrEqual(300); // Accept 302 or 307
expect(res.status).toBeLessThan(400);
});
it("should delete session cookie AND call deleteAll for transaction cookies", async () => {
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
req.cookies.set("__txn_state1", "txn-value1");
req.cookies.set("__txn_state2", "txn-value2");
req.cookies.set("other_cookie", "other-value");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith(req.cookies, res.cookies);
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
});
it("should call deleteAll for transaction cookies even if no session exists", async () => {
mockSessionStoreInstance.get = vi.fn().mockResolvedValue(null);
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__txn_state1", "txn-value1");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith(req.cookies, res.cookies);
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
});
it("should respect custom transaction cookie prefix when calling deleteAll", async () => {
const customPrefix = "__my_txn_";
mockTransactionStoreInstance.getCookiePrefix = vi
.fn()
.mockReturnValue(customPrefix);
authClient = new AuthClient({
...baseOptions,
sessionStore: mockSessionStoreInstance,
transactionStore: mockTransactionStoreInstance
});
authClient.discoverAuthorizationServerMetadata = vi
.fn()
.mockResolvedValue([null, { end_session_endpoint: "http://..." }]);
const req = new NextRequest("http://localhost:3000/api/auth/logout");
req.cookies.set("__session", "session-value");
req.cookies.set(`${customPrefix}state1`, "txn-value1");
req.cookies.set("__txn_state2", "default-prefix-value");
const res = await authClient.handleLogout(req);
expect(mockSessionStoreInstance.delete).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.deleteAll).toHaveBeenCalledWith(req.cookies, res.cookies);
expect(res.status).toBeGreaterThanOrEqual(300);
expect(res.status).toBeLessThan(400);
});
});
describe("handleCallback", () => {
it("should delete the correct transaction cookie on success", async () => {
const state = "test-state";
const req = new NextRequest(`http://localhost:3000/api/auth/callback?code=auth_code&state=${state}`);
const res = await authClient.handleCallback(req);
expect(mockTransactionStoreInstance.get).toHaveBeenCalledWith(req.cookies, state);
expect(mockTransactionStoreInstance.delete).toHaveBeenCalledTimes(1);
expect(mockTransactionStoreInstance.delete).toHaveBeenCalledWith(res.cookies, state);
expect(mockSessionStoreInstance.set).toHaveBeenCalledTimes(1);
expect(res.status).toBeGreaterThanOrEqual(300); // Accept redirects
expect(res.status).toBeLessThan(400);
expect(res.headers.get("location")).toBe("http://localhost:3000/");
});
it("should NOT delete transaction cookie on InvalidStateError", async () => {
const state = "invalid-state";
mockTransactionStoreInstance.get = vi.fn().mockResolvedValue(null);
const req = new NextRequest(`http://localhost:3000/api/auth/callback?code=auth_code&state=${state}`);
const res = await authClient.handleCallback(req);
expect(mockTransactionStoreInstance.get).toHaveBeenCalledWith(req.cookies, state);
expect(mockTransactionStoreInstance.delete).not.toHaveBeenCalled();
expect(mockSessionStoreInstance.set).not.toHaveBeenCalled();
expect(res.status).toBe(500);
const body = await res.text();
expect(body).toContain(new InvalidStateError().message);
});
it("should NOT delete transaction cookie on MissingStateError", async () => {
const req = new NextRequest(`http://localhost:3000/api/auth/callback?code=auth_code`);
const res = await authClient.handleCallback(req);
expect(mockTransactionStoreInstance.get).not.toHaveBeenCalled();
expect(mockTransactionStoreInstance.delete).not.toHaveBeenCalled();
expect(mockSessionStoreInstance.set).not.toHaveBeenCalled();
expect(res.status).toBe(500);
const body = await res.text();
expect(body).toContain(new MissingStateError().message);
});
});
});