@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
344 lines (343 loc) • 16.4 kB
JavaScript
import * as oauth from "oauth4webapi";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getDefaultRoutes } from "../test/defaults.js";
import { generateSecret } from "../test/utils.js";
import { AuthClient } from "./auth-client.js";
import { Fetcher } from "./fetcher.js";
import { StatelessSessionStore } from "./session/stateless-session-store.js";
import { TransactionStore } from "./transaction-store.js";
// Mock oauth4webapi
vi.mock("oauth4webapi", async () => {
const actual = await vi.importActual("oauth4webapi");
return {
...actual,
protectedResourceRequest: vi.fn(),
isDPoPNonceError: vi.fn(),
DPoP: vi.fn()
};
});
describe("Fetcher", () => {
let fetcher;
let mockFetch;
let authClient;
let secret;
const DEFAULT = {
domain: "test.auth0.com",
clientId: "test-client-id",
clientSecret: "test-client-secret",
appBaseUrl: "https://example.com"
};
beforeEach(async () => {
secret = await generateSecret(32);
mockFetch = vi.fn().mockResolvedValue(new Response("OK"));
// Mock oauth functions
oauth.protectedResourceRequest.mockResolvedValue(new Response("OK"));
oauth.DPoP.mockResolvedValue({
privateKey: "test-key",
publicKey: "test-public-key"
});
oauth.isDPoPNonceError.mockReturnValue(false);
// Create a basic authClient
const transactionStore = new TransactionStore({ secret });
const sessionStore = new StatelessSessionStore({ secret });
authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes()
});
const config = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({})
};
const hooks = {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(false)
};
fetcher = new Fetcher(config, hooks);
});
afterEach(() => {
vi.clearAllMocks();
});
describe("basic functionality", () => {
it("should make authenticated requests using oauth.protectedResourceRequest", async () => {
await fetcher.fetchWithAuth("https://api.example.com/data");
// Verify that protectedResourceRequest was called - the core DPoP functionality
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(1);
// Check the first few critical parameters
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[0]).toBe("test-token"); // access token
expect(callArgs[1]).toBe("GET"); // method
expect(callArgs[2].href).toBe("https://api.example.com/data"); // url as URL object
});
it("should handle POST requests", async () => {
const requestInit = {
method: "POST",
body: JSON.stringify({ test: "data" })
};
await fetcher.fetchWithAuth("https://api.example.com/data", requestInit);
expect(oauth.protectedResourceRequest).toHaveBeenCalledWith(expect.any(String), // access token
"POST", // method
expect.anything(), // url
expect.anything(), // headers
expect.anything(), // body
expect.anything() // options
);
});
it("should handle relative URLs", async () => {
await fetcher.fetchWithAuth("/users");
// Verify the relative URL is resolved correctly
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(1);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[0]).toBe("test-token"); // access token
expect(callArgs[1]).toBe("GET"); // method
expect(callArgs[2].href).toBe("https://api.example.com/users"); // url resolved as URL object
});
});
describe("DPoP functionality", () => {
it("should use DPoP when enabled", async () => {
// Create authClient with DPoP enabled
const transactionStore = new TransactionStore({ secret });
const sessionStore = new StatelessSessionStore({ secret });
const dpopAuthClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
useDPoP: true
});
const configWithDpop = {
authClient: dpopAuthClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({})
};
const hooks = {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(true)
};
const dpopFetcher = new Fetcher(configWithDpop, hooks);
await dpopFetcher.fetchWithAuth("https://api.example.com/data");
expect(oauth.protectedResourceRequest).toHaveBeenCalled();
});
it("should work without DPoP when disabled", async () => {
await fetcher.fetchWithAuth("https://api.example.com/data");
expect(oauth.protectedResourceRequest).toHaveBeenCalled();
});
});
describe("parameter disambiguation", () => {
it("should handle fetchWithAuth(url, getAccessTokenOptions) - 2 argument form", async () => {
const getAccessTokenOptions = {
refresh: true,
scope: "read:data"
};
await fetcher.fetchWithAuth("https://api.example.com/data", getAccessTokenOptions);
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(1);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[1]).toBe("GET"); // should default to GET when no RequestInit provided
});
it("should handle fetchWithAuth(url, requestInit) - 2 argument form", async () => {
const requestInit = {
method: "PUT",
headers: { "Content-Type": "application/json" }
};
await fetcher.fetchWithAuth("https://api.example.com/data", requestInit);
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(1);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[1]).toBe("PUT"); // should use method from RequestInit
});
it("should handle fetchWithAuth(url, requestInit, getAccessTokenOptions) - 3 argument form", async () => {
const requestInit = { method: "PATCH" };
const getAccessTokenOptions = { scope: "write:data" };
await fetcher.fetchWithAuth("https://api.example.com/data", requestInit, getAccessTokenOptions);
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(1);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[1]).toBe("PATCH");
});
});
describe("DPoP nonce error retry", () => {
it("should retry on DPoP nonce error", async () => {
// Mock isDPoPNonceError to return true for first call, false for retry
let callCount = 0;
oauth.isDPoPNonceError.mockImplementation(() => {
callCount++;
return callCount === 1; // Return true only for first call
});
// Mock protectedResourceRequest to fail first, succeed on retry
oauth.protectedResourceRequest
.mockRejectedValueOnce(new Error("DPoP nonce error"))
.mockResolvedValueOnce(new Response("OK"));
const result = await fetcher.fetchWithAuth("https://api.example.com/data");
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(2);
expect(result).toBeInstanceOf(Response);
});
it("should respect retry configuration with custom delay", async () => {
const customRetryConfig = {
delay: 50,
jitter: false
};
const configWithRetry = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({}),
retryConfig: customRetryConfig
};
const retryFetcher = new Fetcher(configWithRetry, {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(false)
});
// Mock DPoP nonce error
let callCount = 0;
oauth.isDPoPNonceError.mockImplementation(() => {
callCount++;
return callCount === 1;
});
oauth.protectedResourceRequest
.mockRejectedValueOnce(new Error("DPoP nonce error"))
.mockResolvedValueOnce(new Response("OK"));
const startTime = Date.now();
await retryFetcher.fetchWithAuth("https://api.example.com/data");
const endTime = Date.now();
// Should have taken at least the delay time (accounting for test timing variance)
expect(endTime - startTime).toBeGreaterThanOrEqual(40);
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(2);
});
it("should not retry twice on DPoP nonce error", async () => {
// Mock to always return true for isDPoPNonceError
oauth.isDPoPNonceError.mockReturnValue(true);
oauth.protectedResourceRequest.mockRejectedValue(new Error("DPoP nonce error"));
await expect(fetcher.fetchWithAuth("https://api.example.com/data")).rejects.toThrow("DPoP nonce error");
// Should be called exactly twice - original + one retry
expect(oauth.protectedResourceRequest).toHaveBeenCalledTimes(2);
});
});
describe("URL handling", () => {
it("should handle URL objects", async () => {
const url = new URL("https://api.example.com/data");
await fetcher.fetchWithAuth(url);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[2].href).toBe("https://api.example.com/data");
});
it("should throw error for relative URL without baseUrl", async () => {
const configWithoutBase = {
authClient,
fetch: mockFetch,
httpOptions: () => ({})
// No baseUrl
};
const noBaseFetcher = new Fetcher(configWithoutBase, {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(false)
});
await expect(noBaseFetcher.fetchWithAuth("/relative-path")).rejects.toThrow("Failed to parse URL from /relative-path");
});
it("should handle Request objects as input", async () => {
const request = new Request("https://api.example.com/data", {
method: "POST",
body: "test data"
});
await fetcher.fetchWithAuth(request);
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[1]).toBe("GET"); // Default method used by fetcher
expect(callArgs[2].href).toBe("https://api.example.com/data"); // URL from Request
});
});
describe("DPoP handle integration", () => {
it("should pass DPoP handle to protectedResourceRequest when available", async () => {
const mockDpopHandle = {
privateKey: "test",
publicKey: "test",
calculateThumbprint: vi.fn().mockResolvedValue("thumbprint")
};
const configWithDpopHandle = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({}),
dpopHandle: mockDpopHandle
};
const dpopFetcher = new Fetcher(configWithDpopHandle, {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(true)
});
await dpopFetcher.fetchWithAuth("https://api.example.com/data");
// Verify that DPoP handle was passed in options
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
const options = callArgs[5]; // 6th parameter is options
expect(options.DPoP).toBe(mockDpopHandle);
});
it("should pass allowInsecureRequests when configured", async () => {
const configWithInsecure = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({}),
allowInsecureRequests: true
};
const insecureFetcher = new Fetcher(configWithInsecure, {
getAccessToken: vi.fn().mockResolvedValue("test-token"),
isDpopEnabled: vi.fn().mockReturnValue(false)
});
await insecureFetcher.fetchWithAuth("https://api.example.com/data");
// Verify protectedResourceRequest was called with correct parameters
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[0]).toBe("test-token"); // access token
expect(callArgs[1]).toBe("GET"); // method
expect(callArgs[2].href).toBe("https://api.example.com/data"); // URL
expect(callArgs[3]).toBeInstanceOf(Headers); // headers
expect(callArgs[4]).toBeNull(); // DPoP handle
expect(typeof callArgs[5]).toBe("object"); // options
});
});
describe("access token sources", () => {
it("should use config.getAccessToken when available", async () => {
const configAccessToken = vi.fn().mockResolvedValue("config-token");
const configWithAccessToken = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({}),
getAccessToken: configAccessToken
};
const configTokenFetcher = new Fetcher(configWithAccessToken, {
getAccessToken: vi.fn().mockResolvedValue("hooks-token"),
isDpopEnabled: vi.fn().mockReturnValue(false)
});
await configTokenFetcher.fetchWithAuth("https://api.example.com/data");
expect(configAccessToken).toHaveBeenCalled();
const callArgs = oauth.protectedResourceRequest.mock.calls[0];
expect(callArgs[0]).toBe("config-token"); // Should use config token, not hooks token
});
});
describe("error handling", () => {
it("should handle oauth errors", async () => {
oauth.protectedResourceRequest.mockRejectedValue(new Error("OAuth error"));
await expect(fetcher.fetchWithAuth("https://api.example.com/data")).rejects.toThrow("OAuth error");
});
it("should handle access token errors", async () => {
const hooks = {
getAccessToken: vi.fn().mockRejectedValue(new Error("Token error")),
isDpopEnabled: vi.fn().mockReturnValue(false)
};
const config = {
authClient,
baseUrl: "https://api.example.com",
fetch: mockFetch,
httpOptions: () => ({})
};
const errorFetcher = new Fetcher(config, hooks);
await expect(errorFetcher.fetchWithAuth("https://api.example.com/data")).rejects.toThrow("Token error");
});
});
});