Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/actions/callback/access-token.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/actions/callback/userinfo.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -32,7 +33,7 @@ const getDefaultUserInfo = (profile: Record<string, string>): 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",
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/request.ts
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions packages/core/test/actions/callback/access-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -95,6 +96,7 @@ describe("createAccessToken", async () => {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(bodyParams).toString(),
signal: expect.any(AbortSignal),
})
})

Expand Down Expand Up @@ -126,6 +128,7 @@ describe("createAccessToken", async () => {
...bodyParams,
code: "invalid_code",
}).toString(),
signal: expect.any(AbortSignal),
})
})

Expand Down Expand Up @@ -158,6 +161,7 @@ describe("createAccessToken", async () => {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(bodyParams).toString(),
signal: expect.any(AbortSignal),
})
})
})
2 changes: 2 additions & 0 deletions packages/core/test/actions/callback/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packages/core/test/actions/callback/userinfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("getUserInfo", () => {
Accept: "application/json",
Authorization: "Bearer access_token_123",
},
signal: expect.any(AbortSignal),
})
expect(response).toEqual(mockResponse)
})
Expand Down Expand Up @@ -68,6 +69,7 @@ describe("getUserInfo", () => {
Accept: "application/json",
Authorization: "Bearer access_token_123",
},
signal: expect.any(AbortSignal),
})
expect(response).toEqual({
sub: "12345",
Expand Down Expand Up @@ -110,6 +112,7 @@ describe("getUserInfo", () => {
Accept: "application/json",
Authorization: "Bearer access_token_123",
},
signal: expect.any(AbortSignal),
})
})

Expand All @@ -135,6 +138,7 @@ describe("getUserInfo", () => {
Accept: "application/json",
Authorization: "Bearer invalid_access_token",
},
signal: expect.any(AbortSignal),
})
})

Expand All @@ -154,6 +158,7 @@ describe("getUserInfo", () => {
Accept: "application/json",
Authorization: "Bearer access_token",
},
signal: expect.any(AbortSignal),
})
})
})
21 changes: 14 additions & 7 deletions packages/core/test/actions/session/session.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand Down
95 changes: 95 additions & 0 deletions packages/core/test/request.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})