diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 75ac710..5b13ce3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Implemented timeout handling for `fetch` request used to communicate with external services during OAuth flows and user information retrieval. The implementation introduces a time limit for requests, ensuring they are canceled if they exceed the configured timeout. [#53](https://github.com/aura-stack-ts/auth/pull/53) + +--- + +## [0.2.0] - 2026-1-09 + +### Added + - Added new patterns for automatic loading of environment variables for secure and OAuth credentials. The `AURA_` prefix is now optional for all environment variables required by Aura Auth. [#51](https://github.com/aura-stack-ts/auth/pull/51) - Re-export the `encryptJWE` and `decryptJWE` functions for JWEs (Json Web Encryption) from the `jose` instance created from `createAuth` function. These functions are used internally for session and csrf token management and can be consumed for external reasons designed by the users. [#45](https://github.com/aura-stack-ts/auth/pull/45) diff --git a/packages/core/src/actions/callback/access-token.ts b/packages/core/src/actions/callback/access-token.ts index 3219608..c7339aa 100644 --- a/packages/core/src/actions/callback/access-token.ts +++ b/packages/core/src/actions/callback/access-token.ts @@ -1,3 +1,4 @@ +import { fetchAsync } from "@/request.js" import { formatZodError } from "@/utils.js" import { AuthInternalError, OAuthProtocolError } from "@/errors.js" import { OAuthAccessToken, OAuthAccessTokenErrorResponse, OAuthAccessTokenResponse } from "@/schemas.js" @@ -27,7 +28,7 @@ export const createAccessToken = async ( } const { accessToken, clientId, clientSecret, code: codeParsed, redirectURI: redirectParsed } = parsed.data try { - const response = await fetch(accessToken, { + const response = await fetchAsync(accessToken, { method: "POST", headers: { Accept: "application/json", diff --git a/packages/core/src/actions/callback/userinfo.ts b/packages/core/src/actions/callback/userinfo.ts index d3440f5..2c9df8b 100644 --- a/packages/core/src/actions/callback/userinfo.ts +++ b/packages/core/src/actions/callback/userinfo.ts @@ -1,3 +1,4 @@ +import { fetchAsync } from "@/request.js" import { generateSecure } from "@/secure.js" import { OAuthErrorResponse } from "@/schemas.js" import { isNativeError, isOAuthProtocolError, OAuthProtocolError } from "@/errors.js" @@ -32,7 +33,7 @@ const getDefaultUserInfo = (profile: Record): User => { export const getUserInfo = async (oauthConfig: OAuthProviderCredentials, accessToken: string) => { const userinfoEndpoint = oauthConfig.userInfo try { - const response = await fetch(userinfoEndpoint, { + const response = await fetchAsync(userinfoEndpoint, { method: "GET", headers: { Accept: "application/json", diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts new file mode 100644 index 0000000..f24ad3e --- /dev/null +++ b/packages/core/src/request.ts @@ -0,0 +1,20 @@ +/** + * Fetches a resource with a timeout mechanism. + * + * @param url - The URL or Request object to fetch + * @param options - Optional RequestInit configuration object + * @param timeout - Timeout duration in milliseconds (default: 5000ms) + * @returns A promise that resolves to the Response object + * @example + * const response = await fetchAsync('https://api.example.com/data', {}, 3000); + */ +export const fetchAsync = async (url: string | Request, options: RequestInit = {}, timeout: number = 5000) => { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + const response = await fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => clearTimeout(timeoutId)) + return response +} diff --git a/packages/core/test/actions/callback/access-token.test.ts b/packages/core/test/actions/callback/access-token.test.ts index b0e90f9..bbe1e77 100644 --- a/packages/core/test/actions/callback/access-token.test.ts +++ b/packages/core/test/actions/callback/access-token.test.ts @@ -44,6 +44,7 @@ describe("createAccessToken", async () => { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(bodyParams).toString(), + signal: expect.any(AbortSignal), }) expect(accessToken).toEqual(mockResponse) }) @@ -95,6 +96,7 @@ describe("createAccessToken", async () => { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(bodyParams).toString(), + signal: expect.any(AbortSignal), }) }) @@ -126,6 +128,7 @@ describe("createAccessToken", async () => { ...bodyParams, code: "invalid_code", }).toString(), + signal: expect.any(AbortSignal), }) }) @@ -158,6 +161,7 @@ describe("createAccessToken", async () => { "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(bodyParams).toString(), + signal: expect.any(AbortSignal), }) }) }) diff --git a/packages/core/test/actions/callback/callback.test.ts b/packages/core/test/actions/callback/callback.test.ts index 1678d9f..26a7ef5 100644 --- a/packages/core/test/actions/callback/callback.test.ts +++ b/packages/core/test/actions/callback/callback.test.ts @@ -148,6 +148,7 @@ describe("callbackAction", () => { grant_type: "authorization_code", code_verifier: codeVerifier, }).toString(), + signal: expect.any(AbortSignal), }) expect(fetch).toHaveBeenCalledWith("https://example.com/oauth/userinfo", { @@ -156,6 +157,7 @@ describe("callbackAction", () => { Accept: "application/json", Authorization: "Bearer access_123", }, + signal: expect.any(AbortSignal), }) expect(fetch).toHaveBeenCalledTimes(2) expect(response.status).toBe(302) diff --git a/packages/core/test/actions/callback/userinfo.test.ts b/packages/core/test/actions/callback/userinfo.test.ts index ba65eec..830463e 100644 --- a/packages/core/test/actions/callback/userinfo.test.ts +++ b/packages/core/test/actions/callback/userinfo.test.ts @@ -28,6 +28,7 @@ describe("getUserInfo", () => { Accept: "application/json", Authorization: "Bearer access_token_123", }, + signal: expect.any(AbortSignal), }) expect(response).toEqual(mockResponse) }) @@ -68,6 +69,7 @@ describe("getUserInfo", () => { Accept: "application/json", Authorization: "Bearer access_token_123", }, + signal: expect.any(AbortSignal), }) expect(response).toEqual({ sub: "12345", @@ -110,6 +112,7 @@ describe("getUserInfo", () => { Accept: "application/json", Authorization: "Bearer access_token_123", }, + signal: expect.any(AbortSignal), }) }) @@ -135,6 +138,7 @@ describe("getUserInfo", () => { Accept: "application/json", Authorization: "Bearer invalid_access_token", }, + signal: expect.any(AbortSignal), }) }) @@ -154,6 +158,7 @@ describe("getUserInfo", () => { Accept: "application/json", Authorization: "Bearer access_token", }, + signal: expect.any(AbortSignal), }) }) }) diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index 5858b54..5600564 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -1,7 +1,12 @@ -import { setCookie, getSetCookie, createCookieStore } from "@/cookie.js" +import { describe, test, expect, vi, afterEach } from "vitest" import { createPKCE } from "@/secure.js" import { GET, jose, sessionPayload } from "@test/presets.js" -import { describe, test, expect, vi } from "vitest" +import { setCookie, getSetCookie, createCookieStore } from "@/cookie.js" + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) describe("sessionAction", () => { const { encodeJWT } = jose @@ -98,10 +103,10 @@ describe("sessionAction", () => { test("update default profile function", async () => { const mockFetch = vi.fn() - const cookies = createCookieStore(true) - vi.stubGlobal("fetch", mockFetch) + const cookies = createCookieStore(true) + const accessTokenMock = { access_token: "access_123", token_type: "Bearer", @@ -147,7 +152,7 @@ describe("sessionAction", () => { }) ) - expect(fetch).toHaveBeenCalledWith("https://example.com/oauth/access_token", { + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/access_token", { method: "POST", headers: { Accept: "application/json", @@ -161,16 +166,18 @@ describe("sessionAction", () => { grant_type: "authorization_code", code_verifier: codeVerifier, }).toString(), + signal: expect.any(AbortSignal), }) - expect(fetch).toHaveBeenCalledWith("https://example.com/oauth/userinfo", { + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/userinfo", { method: "GET", headers: { Accept: "application/json", Authorization: "Bearer access_123", }, + signal: expect.any(AbortSignal), }) - expect(fetch).toHaveBeenCalledTimes(2) + expect(mockFetch).toHaveBeenCalledTimes(2) expect(response.status).toBe(302) expect(response.headers.get("Location")).toBe("/auth") const sessionToken = getSetCookie(response, "__Secure-aura-auth.sessionToken") diff --git a/packages/core/test/request.test.ts b/packages/core/test/request.test.ts new file mode 100644 index 0000000..ff92695 --- /dev/null +++ b/packages/core/test/request.test.ts @@ -0,0 +1,95 @@ +import { fetchAsync } from "@/request.js" +import { describe, expect, test, vi } from "vitest" + +describe("fetchAsync", () => { + test("fetch resolved in timeout time", async () => { + const mockFetch = vi.fn().mockResolvedValue("fetched response") + vi.stubGlobal("fetch", mockFetch) + + const request = fetchAsync("https://example.com/timeout", {}) + await expect(request).resolves.toBe("fetched response") + expect(mockFetch).toHaveBeenCalledOnce() + }) + + test("fetch aborted after timeout", async () => { + vi.useFakeTimers() + + const mockFetch = vi.fn().mockImplementation((_, { signal }) => { + return new Promise((_, reject) => { + signal.addEventListener("abort", () => { + reject(new DOMException("Aborted Request", "AbortError")) + }) + }) + }) + vi.stubGlobal("fetch", mockFetch) + const request = fetchAsync("https://example.com/timeout", {}, 1000) + vi.advanceTimersByTime(1050) + + await expect(request).rejects.toThrow("Aborted Request") + expect(mockFetch).toHaveBeenCalledOnce() + + vi.useRealTimers() + }) + + test("fetch with abort is triggered at the correct timeout", async () => { + vi.useFakeTimers() + + const abortSpy = vi.spyOn(AbortController.prototype, "abort") + vi.stubGlobal("fetch", () => new Promise(() => {})) + + fetchAsync("https://example.com/timeout", {}, 2000) + expect(abortSpy).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1999) + expect(abortSpy).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(abortSpy).toHaveBeenCalledOnce() + + vi.useRealTimers() + }) + + test("clears timeout after successful fetch", async () => { + vi.useFakeTimers() + + const clearSpy = vi.spyOn(global, "clearTimeout") + vi.stubGlobal("fetch", vi.fn().mockResolvedValue("OK")) + + await fetchAsync("https://example.com/timeout", {}, 1000) + + expect(clearSpy).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + test("propagates fetch error before timeout", async () => { + vi.useFakeTimers() + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network failure"))) + + const promise = fetchAsync("https://example.com/timeout", {}, 200) + + vi.advanceTimersByTime(100) + + await expect(promise).rejects.toThrow("Network failure") + vi.useRealTimers() + }) + + test("fetch resolving at timeout boundary succeeds", async () => { + vi.useFakeTimers() + + vi.stubGlobal( + "fetch", + () => + new Promise((resolve) => { + setTimeout(() => resolve("OK"), 1000) + }) + ) + + const promise = fetchAsync("https://example.com/timeout", {}, 1000) + + vi.advanceTimersByTime(1000) + + await expect(promise).resolves.toBe("OK") + vi.useRealTimers() + }) +})