From a59990c71bf191390a3a32d25c5270e6fc108955 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 23 Jul 2026 17:37:07 -0500 Subject: [PATCH 1/2] feat(core): support explicit double-submit validation via doubleSubmitToken --- packages/core/CHANGELOG.md | 4 + packages/core/src/@types/api.ts | 21 +++++- packages/core/src/@types/config.ts | 2 +- .../src/actions/providers/tokens/tokens.ts | 1 - packages/core/src/api/createApi.ts | 30 +++++--- packages/core/src/api/getProviderTokens.ts | 4 +- packages/core/src/api/refreshUserInfo.ts | 13 +++- packages/core/src/api/revokeToken.ts | 9 ++- packages/core/src/api/signInCredentials.ts | 3 +- packages/core/src/api/signOut.ts | 3 +- packages/core/src/api/signUp.ts | 3 +- packages/core/src/api/updateSession.ts | 9 ++- packages/core/src/router/context.ts | 2 +- packages/core/src/shared/utils.ts | 17 ----- packages/core/src/shared/utils/api.ts | 44 +++++++++-- .../providers/tokens/tokens/stateful.test.ts | 71 ++---------------- .../providers/tokens/tokens/stateless.test.ts | 10 +-- .../api/stateful/getProviderTokens.test.ts | 75 ++----------------- .../test/api/stateless/getAccessToken.test.ts | 42 ----------- .../api/stateless/getProviderTokens.test.ts | 42 ----------- .../core/test/api/stateless/signOut.test.ts | 2 + 21 files changed, 129 insertions(+), 278 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 77ba4c93..742f2c17 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added the `doubleSubmitToken` option to the APIs exposed by `createAuth().api`, allowing explicit Double-Submit Cookie validation for server-side requests. By default, Double-Submit Cookie validation is skipped in trusted server environments, while standard CSRF validation remains enabled. Supplying `doubleSubmitToken` enforces Double-Submit Cookie validation for the request. [#233](https://github.com/aura-stack-ts/auth/pull/233) + - Added support for inferring OpenID Connect (OIDC) provider issuer slugs from environment variables. Issuer slugs can now be configured either through `provider.slugName` or an environment variable following the `PREFIX_SLUG_NAME` naming convention. [#225](https://github.com/aura-stack-ts/auth/pull/225) - Added the `isProviderConnected()` client API to `createAuthClient()`, providing a client-side interface for the `GET /providers/:provider` endpoint. The API checks whether an OAuth or OpenID Connect (OIDC) provider is currently connected to the active session. [#223](https://github.com/aura-stack-ts/auth/pull/223) @@ -42,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### BREAKING CHANGES +- Deprecated the `skipCSRFCheck` option in the APIs exposed by `createAuth().api`. Use `doubleSubmitToken` instead to explicitly enable Double-Submit Cookie validation. The previous option was misleading because it only disabled the Double-Submit Cookie check while standard CSRF validation continued to be performed. [#233](https://github.com/aura-stack-ts/auth/pull/233) + - Reorganized identity schemas, types, and type guards into dedicated `/identity/:schema` entry points. As part of this change, schema-specific prefixes and suffixes (such as `Zod`) have been removed to provide a consistent API across all supported schema libraries. [#216](https://github.com/aura-stack-ts/auth/pull/216) - Renamed `UserIdentity` to `identitySchema` and moved it to `/identity/:schema`. - Renamed `UserShape` to `IdentityShape` and moved it to `/identity/:schema`. diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index d4d8bacc..11ecb46f 100644 --- a/packages/core/src/@types/api.ts +++ b/packages/core/src/@types/api.ts @@ -116,8 +116,26 @@ export interface APIOptionsWithSkipCSRFCheck { * The CSRF token is still required and validated to preserve request integrity. * Use this only for trusted server-side flows. * @default `false` + * @deprecated Use `doubleSubmitToken` to provide the CSRF token explicitly instead of skipping the check. */ skipCSRFCheck?: boolean + /** + * Optional token used to perform Double-Submit Cookie validation. + * + * By default, server-side API functions skip the Double-Submit Cookie validation + * because they execute in a trusted server environment where the CSRF token is + * not directly available. Providing this value enables the same Double-Submit + * Cookie validation performed by the HTTP endpoints. + * + * Other CSRF protections remain enabled regardless of whether this option is + * provided. + * + * @example + * api.signOut({ + * doubleSubmitToken: "csrf-token-value", + * }) + */ + doubleSubmitToken?: string } /** Options to get the current session. */ @@ -323,8 +341,7 @@ export type SignUpReturn = Options extends { redi /** * Programmatic options for `getProviderTokens` API. */ -export interface GetProviderTokensAPIOptions - extends Pick, APIOptionsWithSkipCSRFCheck {} +export interface GetProviderTokensAPIOptions extends Pick {} export type GetProviderTokensData = { success: true; tokens: OAuthTokenPayload } | { success: false; tokens: null } diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 44b19bcd..2b9206b2 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -486,7 +486,7 @@ export interface RouterGlobalContext jwtManager: JWTManager rateLimiters: InferRules> - sessionStrategyMode: "jwt" | "database" + sessionConfig: SessionConfig } export interface SchemaRegistryContext { diff --git a/packages/core/src/actions/providers/tokens/tokens.ts b/packages/core/src/actions/providers/tokens/tokens.ts index 1fb20b7c..75ea617e 100644 --- a/packages/core/src/actions/providers/tokens/tokens.ts +++ b/packages/core/src/actions/providers/tokens/tokens.ts @@ -25,7 +25,6 @@ export const tokensAction = (oauth: OAuthProviderRecord) => { ctx: ctx.context, request: ctx.request, headers: ctx.request.headers, - skipCSRFCheck: false, }) return toResponse() }, diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index b2b07e5a..71739e77 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -46,6 +46,7 @@ import type { } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SchemaTypes } from "@/identity/index.ts" +import { assertDoubleSubmitToken } from "@/shared/utils/api.ts" type InferSignUp = Wrap>> @@ -97,7 +98,8 @@ export const createAuthAPI = => { - return signInCredentials({ ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options) + return signInCredentials({ ctx, ...options }) }, /** * Signs up a new user on the server-side. It requires a `payload` with the necessary information for @@ -121,7 +123,8 @@ export const createAuthAPI = = InferSignUp>( options: SignUpAPIOptions ): Promise => { - return signUp({ ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options) + return signUp({ ctx, ...options }) }, /** * Updates the current session on the server-side. It allows partial updates to the session object, such as @@ -144,7 +147,8 @@ export const createAuthAPI = ): Promise> => { - return updateSession({ ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options) + return updateSession({ ctx, skipCSRFCheck: true, ...options }) }, /** * Retrieves the access token for a specific OAuth provider on the server-side. @@ -165,7 +169,7 @@ export const createAuthAPI = , options?: GetProviderTokensAPIOptions ): Promise => { - return getProviderTokens(oauth, { ctx, ...options, skipCSRFCheck: true }) + return getProviderTokens(oauth, { ctx, ...options }) }, /** * Retrieves the access token for a specific OAuth provider on the server-side. @@ -187,7 +191,7 @@ export const createAuthAPI = , options?: AccessTokenAPIOptions ): Promise => { - return getAccessToken(oauth, { ctx, ...options, skipCSRFCheck: true }) + return getAccessToken(oauth, { ctx, ...options }) }, /** * Refreshes the user profile data from the OAuth provider on the server-side. It makes a request to the @@ -204,7 +208,8 @@ export const createAuthAPI = , options?: RefreshUserInfoAPIOptions ): Promise> => { - return refreshUserInfo(oauth, { ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options || {}) + return refreshUserInfo(oauth, { ctx, ...options }) }, /** * Revokes the access token for a specific OAuth provider on the server-side. It makes a request to the @@ -223,7 +228,12 @@ export const createAuthAPI = , options?: RevokeTokenAPIOptions ): Promise => { - return revokeToken(oauth, { ctx, ...options, disconnect: false, skipCSRFCheck: true }) + assertDoubleSubmitToken(options || {}) + return revokeToken(oauth, { + ctx, + ...options, + disconnect: false, + }) }, /** * Disconnects the OAuth provider for the current session on the server-side. It removes the association @@ -241,7 +251,8 @@ export const createAuthAPI = , options?: DisconnectProviderAPIOptions ): Promise => { - return disconnectProvider(oauth, { ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options || {}) + return disconnectProvider(oauth, { ctx, ...options }) }, /** * Checks if the current session is connected to a specific OAuth provider on the server-side. It verifies @@ -281,7 +292,8 @@ export const createAuthAPI = => { - return signOut({ ctx, ...options, skipCSRFCheck: true }) + assertDoubleSubmitToken(options) + return signOut({ ctx, ...options }) }, } } diff --git a/packages/core/src/api/getProviderTokens.ts b/packages/core/src/api/getProviderTokens.ts index 937b3462..175716ee 100644 --- a/packages/core/src/api/getProviderTokens.ts +++ b/packages/core/src/api/getProviderTokens.ts @@ -11,14 +11,13 @@ import type { export const getProviderTokens = async ( oauth: LiteralUnion, - { ctx, request: requestInit, headers: headersInit, skipCSRFCheck = false }: FunctionAPIContext + { ctx, request: requestInit, headers: headersInit }: FunctionAPIContext ): Promise => { const initialHeaders = new Headers(headersInit ?? requestInit?.headers) try { const { request, rateLimit } = await createValidation(ctx, initialHeaders) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck) .buildRequest(requestInit, `/providers/${oauth}/tokens`) .verifyRateLimit("getProviderTokens") .execute() @@ -51,6 +50,7 @@ export const getProviderTokens = async ( }, } } catch (error) { + console.error("Error in getProviderTokens:", error) const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") const headers = toUnionHeaders(initialHeaders, secureApiHeaders) diff --git a/packages/core/src/api/refreshUserInfo.ts b/packages/core/src/api/refreshUserInfo.ts index 6f680740..2fe8d6bc 100644 --- a/packages/core/src/api/refreshUserInfo.ts +++ b/packages/core/src/api/refreshUserInfo.ts @@ -16,18 +16,24 @@ import { getUserInfo } from "@/shared/utils/oauth.ts" export const refreshUserInfo = async ( oauth: LiteralUnion, - { ctx, headers: headersInit, request: requestInit, skipCSRFCheck = false }: FunctionAPIContext + { + ctx, + headers: headersInit, + request: requestInit, + skipCSRFCheck = false, + doubleSubmitToken = undefined, + }: FunctionAPIContext ): Promise> => { const { cookies } = ctx try { ctx.logger?.log("OAUTH_USERINFO_REQUEST_INITIATED", { - structuredData: { provider: oauth, skipCSRFCheck }, + structuredData: { provider: oauth, skipCSRFCheck: skipCSRFCheck || Boolean(doubleSubmitToken) }, }) const { provider, headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck) + .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) .buildRequest(requestInit, `/providers/${oauth}/user/refresh`) .verifyRateLimit("refreshUserInfo") .execute() @@ -43,7 +49,6 @@ export const refreshUserInfo = async ( ctx, request: requestInit, headers: headersInit, - skipCSRFCheck, }) if (!success) { ctx.logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { diff --git a/packages/core/src/api/revokeToken.ts b/packages/core/src/api/revokeToken.ts index b45fc04f..cff6ad7c 100644 --- a/packages/core/src/api/revokeToken.ts +++ b/packages/core/src/api/revokeToken.ts @@ -59,19 +59,24 @@ export const revokeToken = async ( headers: headersInit, request: requestInit, skipCSRFCheck = false, + doubleSubmitToken = undefined, disconnect = false, }: FunctionAPIContext & { disconnect?: boolean } ): Promise => { const { cookies } = ctx try { ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauth, operation: disconnect ? "disconnect" : "revoke", skipCSRFCheck }, + structuredData: { + provider: oauth, + operation: disconnect ? "disconnect" : "revoke", + skipCSRFCheck: skipCSRFCheck || Boolean(doubleSubmitToken), + }, }) const { provider, headers, request, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck) + .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) .buildRequest(requestInit, `/providers/${oauth}/tokens/revoke`) .verifyRateLimit("revokeToken") .execute() diff --git a/packages/core/src/api/signInCredentials.ts b/packages/core/src/api/signInCredentials.ts index a50276c6..1d24b8eb 100644 --- a/packages/core/src/api/signInCredentials.ts +++ b/packages/core/src/api/signInCredentials.ts @@ -13,11 +13,12 @@ export const signInCredentials = async ({ redirect = true, redirectTo, skipCSRFCheck = false, + doubleSubmitToken = undefined, }: FunctionAPIContext): Promise => { const { cookies, credentials, sessionStrategy, logger } = ctx try { const { request, rateLimit } = await createValidation(ctx, headerInit) - .verifyCSRFToken(skipCSRFCheck) + .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) .buildRequest(requestInit, "/signIn/credentials") .verifyRateLimit("signInCredentials") .execute() diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts index 7490906f..bea577e8 100644 --- a/packages/core/src/api/signOut.ts +++ b/packages/core/src/api/signOut.ts @@ -9,10 +9,11 @@ export const signOut = async ({ redirect = true, redirectTo, skipCSRFCheck = false, + doubleSubmitToken = undefined, }: FunctionAPIContext): Promise => { let responseHeaders = new Headers(headersInit) try { - responseHeaders = await ctx.sessionStrategy.destroySession(responseHeaders, skipCSRFCheck) + responseHeaders = await ctx.sessionStrategy.destroySession(responseHeaders, skipCSRFCheck || Boolean(doubleSubmitToken)) const { request } = await createValidation(ctx, responseHeaders).buildRequest(requestInit, "/signOut").execute() const headersBuilder = new HeadersBuilder(responseHeaders) diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts index f96d5c71..e5aa74f0 100644 --- a/packages/core/src/api/signUp.ts +++ b/packages/core/src/api/signUp.ts @@ -13,11 +13,12 @@ export const signUp = async = Record>): Promise => { const { signUp, cookies, sessionStrategy, logger } = ctx try { const { request, rateLimit } = await createValidation(ctx, headersInit) - .verifyCSRFToken(skipCSRFCheck) + .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) .buildRequest(requestInit, "/signUp") .verifyRateLimit("signUp") .execute() diff --git a/packages/core/src/api/updateSession.ts b/packages/core/src/api/updateSession.ts index 4d768689..66f5371b 100644 --- a/packages/core/src/api/updateSession.ts +++ b/packages/core/src/api/updateSession.ts @@ -12,12 +12,14 @@ export const updateSession = async ({ session: sessionInit, redirectTo: redirectToInit, skipCSRFCheck = false, + doubleSubmitToken = undefined, }: FunctionAPIContext>): Promise> => { try { + console.log("double submit token:", skipCSRFCheck || Boolean(doubleSubmitToken)) const { session, headers } = await ctx.sessionStrategy.refreshSession( new Headers(headersInit), sessionInit, - skipCSRFCheck + skipCSRFCheck || Boolean(doubleSubmitToken) ) if (!session) { throw new AuraAuthError({ code: "UPDATE_SESSION_INVALID" }) @@ -61,7 +63,8 @@ export const updateSession = async ({ }, } as UpdateSessionAPIReturn } catch (error) { - const { code, message } = handleApiError(error, "UPDATE_SESSION_INVALID", "Failed to update session.") + console.error("Error in updateSession API:", error) + const { code, message, statusCode } = handleApiError(error, "UPDATE_SESSION_INVALID", "Failed to update session.") const headers = new Headers(secureApiHeaders) return { @@ -74,7 +77,7 @@ export const updateSession = async ({ toResponse: () => { return Response.json( { success: false, session: null, redirect: false, redirectURL: null }, - { status: 400, headers } + { status: statusCode, headers } ) }, } diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 422d14ee..c7ee8d2e 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -63,7 +63,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ cookies: () => ctx.cookies, diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 3de9a675..8d12ea3d 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -149,23 +149,6 @@ export const verifySessionToken = async ({ } } -export const verifyPresentSessionValue = async ({ - headers, - cookies, - logger, -}: { - headers: Headers - cookies: InternalCookieStoreConfig - logger: InternalLogger | undefined -}) => { - try { - return getCookie(headers, cookies.sessionToken.name) - } catch (cause) { - logger?.log("SESSION_NOT_FOUND") - throw new AuraAuthError({ code: "SESSION_NOT_FOUND", cause }) - } -} - export const verifyCSRFToken = async ({ headers, skipCSRFCheck, diff --git a/packages/core/src/shared/utils/api.ts b/packages/core/src/shared/utils/api.ts index 09105c75..c70ad1dd 100644 --- a/packages/core/src/shared/utils/api.ts +++ b/packages/core/src/shared/utils/api.ts @@ -1,7 +1,7 @@ import { HeadersBuilder } from "@aura-stack/router" import { verifyRateLimit } from "@/router/rate-limiter.ts" import { AuraAuthError, isAuraAuthError } from "@/shared/errors.ts" -import { verifyCSRFToken, verifyPresentSessionValue, verifySessionToken } from "@/shared/utils.ts" +import { verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" import { getBaseURL, getOriginURL, createRedirectTo } from "@/shared/utils/authorization.ts" import type { BuiltInOAuthProvider, @@ -9,7 +9,16 @@ import type { RouterGlobalContext, RateLimiterConfig, RuntimeOAuthProvider, + UpdateSessionAPIOptions, + SignInCredentialsAPIOptions, + SignUpAPIOptions, + RefreshUserInfoAPIOptions, + RevokeTokenAPIOptions, + DisconnectProviderAPIOptions, + SignOutAPIOptions, } from "@/@types/index.ts" +import { isStatelessStrategy } from "../assert.ts" +import { createCookieManager } from "@/session/cookie-manager.ts" export const createValidation = (ctx: RouterGlobalContext, headersInit?: HeadersInit) => { const headers = new Headers(headersInit) @@ -38,19 +47,19 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers }, verifySession: () => { steps.push(async () => { - if (ctx.sessionStrategyMode === "database") { - await verifyPresentSessionValue({ - headers: output.headers, - cookies: ctx.cookies, - logger: ctx.logger, - }) - } else { + if (isStatelessStrategy(ctx.sessionConfig)) { await verifySessionToken({ headers: output.headers, cookies: ctx.cookies, jwt: ctx.jwtManager, logger: ctx.logger, }) + } else { + const { sessionToken } = createCookieManager(() => ctx.cookies).getCookie(new Headers(headersInit)) + const session = await ctx.sessionConfig.adapter.getSessionByToken(sessionToken) + if (!session) { + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } } }) return builder @@ -145,3 +154,22 @@ export const resolveApiRedirect = async ( redirectURL: redirectInit ? null : redirectURL, } } + +export const assertDoubleSubmitToken = ( + options: + | UpdateSessionAPIOptions + | SignInCredentialsAPIOptions + | SignUpAPIOptions + | RefreshUserInfoAPIOptions + | RevokeTokenAPIOptions + | DisconnectProviderAPIOptions + | SignOutAPIOptions +) => { + if (options.doubleSubmitToken) { + options.skipCSRFCheck = false + options.headers = new Headers(options.headers) + options.headers.set("x-csrf-token", options.doubleSubmitToken) + } else { + options.skipCSRFCheck = true + } +} diff --git a/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts index 96618d12..950087e1 100644 --- a/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts +++ b/packages/core/test/actions/providers/tokens/tokens/stateful.test.ts @@ -61,71 +61,6 @@ describe("tokensAction (Stateful)", async () => { expect(updateOAuthTokensMock).not.toHaveBeenCalled() }) - test("should return 403 if CSRF token is missing", async () => { - const getSessionByTokenMock = vi.fn() - const getOAuthAccountMock = vi.fn() - const updateOAuthTokensMock = vi.fn() - - const { - handlers: { GET }, - } = authInstance({ - getSessionByToken: getSessionByTokenMock, - getOAuthAccount: getOAuthAccountMock, - updateOAuthTokens: updateOAuthTokensMock, - }) - - const sessionToken = "valid-session-token" - - const response = await GET( - new Request("https://example.com/auth/providers/oauth-provider/tokens", { - headers: { Cookie: `__Secure-aura-auth.session_token=${sessionToken}` }, - }) - ) - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - success: false, - tokens: null, - }) - expect(getSessionByTokenMock).not.toHaveBeenCalled() - expect(getOAuthAccountMock).not.toHaveBeenCalled() - expect(updateOAuthTokensMock).not.toHaveBeenCalled() - }) - - test("should return 403 if CSRF token is invalid", async () => { - vi.stubEnv("BASE_URL", "https://example.com") - - const getSessionByTokenMock = vi.fn() - const getOAuthAccountMock = vi.fn() - const updateOAuthTokensMock = vi.fn() - - const { - handlers: { GET }, - } = authInstance({ - getSessionByToken: getSessionByTokenMock, - getOAuthAccount: getOAuthAccountMock, - updateOAuthTokens: updateOAuthTokensMock, - }) - - const sessionToken = "valid-session-token" - - const response = await GET( - new Request("https://example.com/auth/providers/oauth-provider/tokens", { - headers: { - "x-csrf-token": "invalid-token", - Cookie: `__Secure-aura-auth.session_token=${sessionToken}`, - }, - }) - ) - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - success: false, - tokens: null, - }) - expect(getSessionByTokenMock).not.toHaveBeenCalled() - expect(getOAuthAccountMock).not.toHaveBeenCalled() - expect(updateOAuthTokensMock).not.toHaveBeenCalled() - }) - test("should return 401 if session is not found in database", async () => { vi.stubEnv("BASE_URL", "https://example.com") @@ -666,7 +601,11 @@ describe("tokensAction (Stateful)", async () => { test("automatically refreshes the token when its lifetime falls inside the refresh window", async () => { vi.stubEnv("BASE_URL", "https://example.com") - const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getSessionByTokenMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) const getOAuthAccountMock = vi.fn().mockResolvedValue({ accountId: "account-123", diff --git a/packages/core/test/actions/providers/tokens/tokens/stateless.test.ts b/packages/core/test/actions/providers/tokens/tokens/stateless.test.ts index e6a8479a..d13d2317 100644 --- a/packages/core/test/actions/providers/tokens/tokens/stateless.test.ts +++ b/packages/core/test/actions/providers/tokens/tokens/stateless.test.ts @@ -41,7 +41,7 @@ describe("tokensAction", async () => { headers: { Cookie: `__Secure-aura-auth.session_token=${sessionToken}` }, }) ) - expect(response.status).toBe(403) + expect(response.status).toBe(401) expect(await response.json()).toEqual({ success: false, tokens: null, @@ -59,14 +59,14 @@ describe("tokensAction", async () => { }, }) ) - expect(response.status).toBe(403) + expect(response.status).toBe(401) expect(await response.json()).toEqual({ success: false, tokens: null, }) }) - test("should return 403 if provider token doesn not exist", async () => { + test("should return 401 if provider token doesn not exist", async () => { const csrfToken = await createCSRF(jose) const sessionToken = await jose.encodeJWT(sessionPayload) @@ -74,11 +74,11 @@ describe("tokensAction", async () => { new Request("https://example.com/auth/providers/oauth-provider/tokens", { headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Secure-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}`, }, }) ) - expect(response.status).toBe(403) + expect(response.status).toBe(401) expect(await response.json()).toEqual({ success: false, tokens: null, diff --git a/packages/core/test/api/stateful/getProviderTokens.test.ts b/packages/core/test/api/stateful/getProviderTokens.test.ts index 5ce0fa4d..6318c3c3 100644 --- a/packages/core/test/api/stateful/getProviderTokens.test.ts +++ b/packages/core/test/api/stateful/getProviderTokens.test.ts @@ -34,69 +34,7 @@ describe("getProviderTokens API (Stateful)", () => { }) test("throws error when session token is missing", async () => { - const getSessionByTokenMock = vi.fn() - const getOAuthAccountMock = vi.fn() - const updateOAuthTokensMock = vi.fn() - - const { api } = authInstance({ - getSessionByToken: getSessionByTokenMock, - getOAuthAccount: getOAuthAccountMock, - updateOAuthTokens: updateOAuthTokensMock, - }) - - const output = await api.getProviderTokens("oauth-provider", { headers: new Headers() }) - expect(output).toEqual({ - success: false, - tokens: null, - error: { - code: "SESSION_NOT_FOUND", - message: "The session token is not found. There is no active session.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - expect(getSessionByTokenMock).not.toHaveBeenCalled() - expect(getOAuthAccountMock).not.toHaveBeenCalled() - expect(updateOAuthTokensMock).not.toHaveBeenCalled() - }) - - test("throws error when CSRF token is missing", async () => { - const getSessionByTokenMock = vi.fn() - const getOAuthAccountMock = vi.fn() - const updateOAuthTokensMock = vi.fn() - - const { api } = authInstance({ - getSessionByToken: getSessionByTokenMock, - getOAuthAccount: getOAuthAccountMock, - updateOAuthTokens: updateOAuthTokensMock, - }) - - const sessionToken = "valid-session-token" - - const output = await api.getProviderTokens("oauth-provider", { - headers: { - Cookie: `aura-auth.session_token=${sessionToken}`, - }, - }) - expect(output).toEqual({ - success: false, - tokens: null, - error: { - code: "CSRF_TOKEN_MISSING", - message: "The CSRF token is missing. Please refresh and try again.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - expect(getSessionByTokenMock).not.toHaveBeenCalled() - expect(getOAuthAccountMock).not.toHaveBeenCalled() - expect(updateOAuthTokensMock).not.toHaveBeenCalled() - }) - - test("throws error when CSRF token is invalid", async () => { - vi.stubEnv("BASE_URL", "https://example.com") - - const getSessionByTokenMock = vi.fn() + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(null) const getOAuthAccountMock = vi.fn() const updateOAuthTokensMock = vi.fn() @@ -106,25 +44,22 @@ describe("getProviderTokens API (Stateful)", () => { updateOAuthTokens: updateOAuthTokensMock, }) - const sessionToken = "valid-session-token" - const output = await api.getProviderTokens("oauth-provider", { headers: new Headers({ - Cookie: `aura-auth.csrf_token=invalid-token; aura-auth.session_token=${sessionToken}`, - "X-CSRF-Token": "invalid-token", + Cookie: "aura-auth.session_token=invalid-token", }), }) expect(output).toEqual({ success: false, tokens: null, error: { - code: "CSRF_TOKEN_MISMATCH", - message: "CSRF token verification failed. Please refresh and try again.", + code: "SESSION_NOT_FOUND", + message: "The session token is not found. There is no active session.", }, headers: expect.any(Headers), toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenCalledOnce() expect(getOAuthAccountMock).not.toHaveBeenCalled() expect(updateOAuthTokensMock).not.toHaveBeenCalled() }) diff --git a/packages/core/test/api/stateless/getAccessToken.test.ts b/packages/core/test/api/stateless/getAccessToken.test.ts index 3ecfc6d0..e58eee9e 100644 --- a/packages/core/test/api/stateless/getAccessToken.test.ts +++ b/packages/core/test/api/stateless/getAccessToken.test.ts @@ -34,48 +34,6 @@ describe("getAccessToken API", () => { }) }) - test("throws error when CSRF token is missing", async () => { - const sessionToken = await jose.encodeJWT(sessionPayload) - - const output = await api.getAccessToken("oauth-provider", { - headers: { - Cookie: `aura-auth.session_token=${sessionToken}`, - }, - }) - expect(output).toEqual({ - success: false, - accessToken: null, - error: { - code: "CSRF_TOKEN_MISSING", - message: "The CSRF token is missing. Please refresh and try again.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - }) - - test("throws error when CSRF token is invalid", async () => { - vi.stubEnv("BASE_URL", "https://example.com") - const sessionToken = await jose.encodeJWT(sessionPayload) - - const output = await api.getAccessToken("oauth-provider", { - headers: new Headers({ - Cookie: `aura-auth.csrf_token=invalid-token; aura-auth.session_token=${sessionToken}`, - "X-CSRF-Token": "invalid-token", - }), - }) - expect(output).toEqual({ - success: false, - accessToken: null, - error: { - code: "CSRF_TOKEN_MISMATCH", - message: "CSRF token verification failed. Please refresh and try again.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - }) - test("throws error when provider token does not exist", async () => { vi.stubEnv("BASE_URL", "https://example.com") const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/api/stateless/getProviderTokens.test.ts b/packages/core/test/api/stateless/getProviderTokens.test.ts index d683d383..1fb2a5b4 100644 --- a/packages/core/test/api/stateless/getProviderTokens.test.ts +++ b/packages/core/test/api/stateless/getProviderTokens.test.ts @@ -34,48 +34,6 @@ describe("getProviderTokens API", () => { }) }) - test("throws error when CSRF token is missing", async () => { - const sessionToken = await jose.encodeJWT(sessionPayload) - - const output = await api.getProviderTokens("oauth-provider", { - headers: { - Cookie: `aura-auth.session_token=${sessionToken}`, - }, - }) - expect(output).toEqual({ - success: false, - tokens: null, - error: { - code: "CSRF_TOKEN_MISSING", - message: "The CSRF token is missing. Please refresh and try again.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - }) - - test("throws error when CSRF token is invalid", async () => { - vi.stubEnv("BASE_URL", "https://example.com") - const sessionToken = await jose.encodeJWT(sessionPayload) - - const output = await api.getProviderTokens("oauth-provider", { - headers: new Headers({ - Cookie: `aura-auth.csrf_token=invalid-token; aura-auth.session_token=${sessionToken}`, - "X-CSRF-Token": "invalid-token", - }), - }) - expect(output).toEqual({ - success: false, - tokens: null, - error: { - code: "CSRF_TOKEN_MISMATCH", - message: "CSRF token verification failed. Please refresh and try again.", - }, - headers: expect.any(Headers), - toResponse: expect.any(Function), - }) - }) - test("throws error when provider token does not exist", async () => { vi.stubEnv("BASE_URL", "https://example.com") const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/api/stateless/signOut.test.ts b/packages/core/test/api/stateless/signOut.test.ts index f19816bb..0f51f77f 100644 --- a/packages/core/test/api/stateless/signOut.test.ts +++ b/packages/core/test/api/stateless/signOut.test.ts @@ -118,4 +118,6 @@ describe("signOut API", async () => { toResponse: expect.any(Function), }) }) + + test("") }) From fa58436f2aa1e0f709acdf258b0c90de3167c228 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 24 Jul 2026 14:29:25 -0500 Subject: [PATCH 2/2] test(core): verify doubleSubmitToken and skipCSRFCheck --- .../server/api/refreshUserInfo.mdx | 37 +++- .../api-reference/server/api/revokeToken.mdx | 37 +++- .../server/api/signInCredentials.mdx | 33 ++-- .../api-reference/server/api/signOut.mdx | 43 +++-- .../api-reference/server/api/signUp.mdx | 46 ++++- .../server/api/updateSession.mdx | 43 ++++- packages/core/src/api/createApi.ts | 4 +- packages/core/src/api/getProviderTokens.ts | 1 - packages/core/src/api/refreshUserInfo.ts | 2 +- packages/core/src/api/revokeToken.ts | 4 +- packages/core/src/api/signInCredentials.ts | 2 +- packages/core/src/api/signOut.ts | 4 +- packages/core/src/api/signUp.ts | 2 +- packages/core/src/api/updateSession.ts | 4 +- packages/core/src/shared/utils/api.ts | 8 +- .../api/stateless/refreshUserInfo.test.ts | 180 ++++++++++++++++++ .../test/api/stateless/revokeToken.test.ts | 158 +++++++++++++++ .../api/stateless/signInCredentials.test.ts | 108 +++++++++++ .../core/test/api/stateless/signOut.test.ts | 100 +++++++++- .../core/test/api/stateless/signUp.test.ts | 94 +++++++++ .../test/api/stateless/updateSession.test.ts | 69 +++++++ 21 files changed, 904 insertions(+), 75 deletions(-) diff --git a/docs/src/content/docs/(core)/api-reference/server/api/refreshUserInfo.mdx b/docs/src/content/docs/(core)/api-reference/server/api/refreshUserInfo.mdx index e1af9479..3dda0795 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/refreshUserInfo.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/refreshUserInfo.mdx @@ -24,11 +24,12 @@ const output = await api.refreshUserInfo("github", { ### RefreshUserInfoAPIOptions -| Option | Type | Default | Description | -| ------------- | --------- | --------- | --------------------------------------------------------- | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| skipCSRFCheck | `boolean` | `false` | Whether to skip CSRF token verification for this request. | +| Option | Type | Default | Description | +| ----------------- | --------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | At least one of `request` or `headers` is required to construct the incoming URL and validate redirect URLs. @@ -43,7 +44,15 @@ const output = await api.refreshUserInfo("github", { ## Behavior - The method uses the provider's existing access token to call its `userInfo` endpoint, then normalizes the response through the provider's `profile` function into a `User` object, and returns the updated session. -- CSRF protection is verified by default. Set `skipCSRFCheck: true` only if you're enforcing CSRF protection at a different layer (e.g. a framework-level middleware) — disabling it here without an equivalent safeguard reintroduces the vulnerability it protects against. +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - If the refresh fails (invalid or expired access token, or the provider request fails), `session` resolves to `null` and `success` is `false`. ## Usage @@ -81,8 +90,22 @@ console.log("refreshed session:", session) ```ts title="skip-csrf.ts" lineNumbers import { api } from "@/lib/auth" +const { success, session } = await api.refreshUserInfo("github", { + headers: { + "x-csrf-token": getCSRFTokenFromRequest(), + }, + request: getRequest(), + skipCSRFCheck: false, +}) +``` + +### Enable Double-Submit Cookie CSRF protection + +```ts title="double-submit.ts" lineNumbers +import { api } from "@/lib/auth" + const { success, session } = await api.refreshUserInfo("github", { request: getRequest(), - skipCSRFCheck: true, // only if enforced elsewhere — see the warning above + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), }) ``` diff --git a/docs/src/content/docs/(core)/api-reference/server/api/revokeToken.mdx b/docs/src/content/docs/(core)/api-reference/server/api/revokeToken.mdx index aca50473..24eaf215 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/revokeToken.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/revokeToken.mdx @@ -35,11 +35,12 @@ const output = await api.revokeToken("github", { ### RevokeTokenAPIOptions -| Option | Type | Default | Description | -| ------------- | --------- | --------- | --------------------------------------------------------- | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| skipCSRFCheck | `boolean` | `false` | Whether to skip CSRF token verification for this request. | +| Option | Type | Default | Description | +| ----------------- | --------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | At least one of `request` or `headers` is required to construct the incoming URL and validate redirect URLs. @@ -54,7 +55,15 @@ const output = await api.revokeToken("github", { ## Behavior - Revocation success depends entirely on the provider's `revokeToken` endpoint configuration and whether the provider supports token revocation at all. If it doesn't, this method returns `success: false`. -- CSRF protection is verified by default. Set `skipCSRFCheck: true` only if you're enforcing CSRF protection at a different layer (e.g. a framework-level middleware) — disabling it here without an equivalent safeguard reintroduces the vulnerability it protects against. +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - If revocation fails (no active token for the provider, or the provider rejects the request), `success` is `false`. ## Usage @@ -88,8 +97,22 @@ if (!success) { ```ts title="skip-csrf.ts" lineNumbers import { api } from "@/lib/auth" +const { success } = await api.revokeToken("github", { + headers: { + "x-csrf-token": getCSRFTokenFromRequest(), + }, + request: getRequest(), + skipCSRFCheck: false, // only if enforced elsewhere — see the warning above +}) +``` + +### Enable Double-Submit Cookie CSRF protection + +```ts title="double-submit.ts" lineNumbers +import { api } from "@/lib/auth" + const { success } = await api.revokeToken("github", { request: getRequest(), - skipCSRFCheck: true, // only if enforced elsewhere — see the warning above + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), }) ``` diff --git a/docs/src/content/docs/(core)/api-reference/server/api/signInCredentials.mdx b/docs/src/content/docs/(core)/api-reference/server/api/signInCredentials.mdx index a3cc8b7f..a3ff9831 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/signInCredentials.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/signInCredentials.mdx @@ -28,14 +28,15 @@ const output = await api.signInCredentials({ ### SignInCredentialsAPIOptions -| Option | Type | Default | Description | -| ------------- | -------------------- | --------- | --------------------------------------------------------------------------------------------------- | -| payload | `CredentialsPayload` | — | The credentials for the sign-in attempt. Required — without it there's nothing to authenticate. | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | -| redirectTo | `string` | undefined | The URL to redirect to after a successful sign-in. | -| skipCSRFCheck | `boolean` | `false` | Whether to skip CSRF token verification for this request. | +| Option | Type | Default | Description | +| ----------------- | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| payload | `CredentialsPayload` | — | The credentials for the sign-in attempt. Required — without it there's nothing to authenticate. | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | +| redirectTo | `string` | undefined | The URL to redirect to after a successful sign-in. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | At least one of `request` or `headers` is required to construct the incoming URL and validate redirect URLs. @@ -50,7 +51,15 @@ const output = await api.signInCredentials({ ## Behavior - Sign-in success depends entirely on your configured [`credentials.authorize`](/docs/api-reference/server/createAuth#credentials) function, which validates the submitted credentials and returns the `User` object (or `null` if invalid). -- CSRF protection is verified by default. Set `skipCSRFCheck: true` only if you're enforcing CSRF protection at a different layer (e.g. a framework-level middleware) — disabling it here without an equivalent safeguard reintroduces the vulnerability it protects against. +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - The return value depends on the `redirect` option: - **`redirect: true`** (default) — redirects to `redirectTo` (or the default post-login URL) via the `Location` header. - **`redirect: false`** — resolves to an object with `redirectURL`, `redirect: false`, and `success`, letting you handle navigation yourself. @@ -117,9 +126,9 @@ if (output.success) { } ``` -### Delegating CSRF protection to a different layer +### Enable Double-Submit Cookie CSRF protection -```ts title="skip-csrf.ts" lineNumbers +```ts title="double-submit.ts" lineNumbers import { api } from "@/lib/auth" const output = await api.signInCredentials({ @@ -128,6 +137,6 @@ const output = await api.signInCredentials({ password: "password", }, request: getRequest(), - skipCSRFCheck: true, // only if enforced elsewhere — see the warning above + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), }) ``` diff --git a/docs/src/content/docs/(core)/api-reference/server/api/signOut.mdx b/docs/src/content/docs/(core)/api-reference/server/api/signOut.mdx index 3956eb40..ab528ab7 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/signOut.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/signOut.mdx @@ -24,19 +24,14 @@ const output = await api.signOut({ ### SignOutAPIOptions -| Option | Type | Default | Description | -| ------------- | --------- | --------- | --------------------------------------------------------------------------------------------------- | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | -| redirectTo | `string` | undefined | The URL to redirect to after sign-out completes. | -| skipCSRFCheck | `boolean` | `false` | Whether to skip CSRF token verification for this request. See the [warning](#behavior) below. | - - - At least one of `request` or `headers` is required to construct the incoming URL and validate redirect URLs. Per the [`POST - /auth/signOut` handler](/docs/api-reference/server/handlers#post-authsignout), a valid CSRF token must also be present (as a - cookie and, at the HTTP layer, the `X-CSRF-Token` header) unless `skipCSRFCheck` is set. - +| Option | Type | Default | Description | +| ----------------- | --------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | +| redirectTo | `string` | undefined | The URL to redirect to after sign-out completes. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | ### Returns @@ -46,7 +41,15 @@ const output = await api.signOut({ ## Behavior -- CSRF protection is verified by default, matching the [`signOut` HTTP handler](/docs/api-reference/server/handlers#post-authsignout)'s double-submit cookie check. Set `skipCSRFCheck: true` only if CSRF is enforced at a different layer. +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - The return value depends on the `redirect` option: - **`redirect: true`** (default) — redirects to `redirectTo` (or the default post-sign-out URL) via the `Location` header. - **`redirect: false`** — resolves to an object with `redirectURL`, `redirect: false`, and `success`, letting you handle navigation yourself. @@ -79,3 +82,15 @@ if (output.success) { redirect(output.redirectURL) } ``` + +### Enable Double-Submit Cookie CSRF protection + +```ts title="double-submit.ts" lineNumbers +import { api } from "@/lib/auth" + +const output = await api.signOut({ + headers: getHeaders(), + redirectTo: "/login", + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), +}) +``` diff --git a/docs/src/content/docs/(core)/api-reference/server/api/signUp.mdx b/docs/src/content/docs/(core)/api-reference/server/api/signUp.mdx index 9e4830a2..fff57484 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/signUp.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/signUp.mdx @@ -30,14 +30,15 @@ const output = api.signUp({ ### SignUpOptions -| Option | Type | Default | Description | -| ------------- | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------ | -| payload | `Record` | undefined | The user registration data. | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| redirect | `boolean` | true | Whether to redirect the user after successful sign-in by `Location` header or returns an object result | -| redirectTo | `string` | undefined | The URL to redirect to after successful sign-in. | -| skipCSRFCheck | `boolean` | true | Whether to skip the CSRF token check for the sign-in process. | +| Option | Type | Default | Description | +| ----------------- | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| payload | `Record` | undefined | The user registration data. | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| redirect | `boolean` | true | Whether to redirect the user after successful sign-in by `Location` header or returns an object result | +| redirectTo | `string` | undefined | The URL to redirect to after successful sign-in. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | ### Returns @@ -50,7 +51,16 @@ const output = api.signUp({ ## Behavior - The complete success of the sign-up process depends on `signUp.onCreateUser` function that verifies the provided user data and returns the `User` object if the data is valid or `null` if it is invalid -- The function implements CSRF Token protection by default, it checks the integrity of the csrf token header. But for a strict Double Submit Cookie pattern you can set the `skipCSRFCheck` option to `false` to enforce the CSRF token check. + +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - The behavior of `api.signUp` function depends on the `redirect` option: - If `redirect: true`, the user will be redirected to the authorize URL of the specified OAuth provider using the `Location` header in the request. - If `redirect: false`, the function will return an object containing `redirectURL`, `redirect` and `success` properties. The `redirectURL` is the URL to which the user should be redirected for sign-up auth flow. @@ -129,3 +139,21 @@ if (output.success) { redirect(output.redirectURL) } ``` + +### Enable Double-Submit Cookie CSRF protection + +```ts title="double-submit.ts" lineNumbers +import { api } from "@/lib/auth" + +const output = await api.signUp({ + payload: { + username: "John Doe", + nickname: "johndoe", + email: "johndoe@example.com", + password: "password", + }, + request: getRequest(), + redirectTo: "/dashboard", + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), +}) +``` diff --git a/docs/src/content/docs/(core)/api-reference/server/api/updateSession.mdx b/docs/src/content/docs/(core)/api-reference/server/api/updateSession.mdx index 23079cc7..064f2c90 100644 --- a/docs/src/content/docs/(core)/api-reference/server/api/updateSession.mdx +++ b/docs/src/content/docs/(core)/api-reference/server/api/updateSession.mdx @@ -28,14 +28,15 @@ const session = await api.updateSession({ ### UpdateSessionAPIOptions -| Option | Type | Default | Description | -| ------------- | ----------------------------------- | --------- | --------------------------------------------------------------------------------------------------- | -| session | `DeepPartial>` | undefined | The partial session data to merge into the current session. | -| request | `Request` | undefined | The request object from the server-side context. | -| headers | `Headers` | undefined | The headers object from the server-side context. | -| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | -| redirectTo | `string` | undefined | The URL to redirect to after a successful update. | -| skipCSRFCheck | `boolean` | `false` | Whether to skip CSRF token verification for this request. See the [warning](#behavior) below. | +| Option | Type | Default | Description | +| ----------------- | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | +| session | `DeepPartial>` | undefined | The partial session data to merge into the current session. | +| request | `Request` | undefined | The request object from the server-side context. | +| headers | `Headers` | undefined | The headers object from the server-side context. | +| redirect | `boolean` | `true` | Whether to redirect via the `Location` header (`true`) or return the result as an object (`false`). | +| redirectTo | `string` | undefined | The URL to redirect to after a successful update. | +| ~~skipCSRFCheck~~ | `boolean` | `false` | ~~Deprecated~~. This option has been replaced by `doubleSubmitToken` and will be removed in a future release. | +| doubleSubmitToken | `string` | undefined | Optional CSRF token used to explicitly enable Double-Submit Cookie validation for server-side API functions. | At least one of `request` or `headers` is required to construct the incoming URL and validate redirect URLs. @@ -50,7 +51,15 @@ const session = await api.updateSession({ ## Behavior - The session token's integrity is verified before applying the update. If the session is invalid or expired, the call resolves with `success: false` rather than throwing. -- CSRF protection is verified by default. Set `skipCSRFCheck: true` only if CSRF is enforced at a different layer. +- The `doubleSubmitToken` option enables Double-Submit Cookie validation when calling Aura Auth server-side API functions. By default, API functions execute in a trusted server environment, where browser-based CSRF attacks are not possible because requests originate from server-side code rather than from a user's browser. For that reason, Aura Auth performs the standard CSRF validation but skips the additional Double-Submit Cookie verification. + + This option is intended for advanced scenarios where server-side requests should enforce the same CSRF guarantees as browser-initiated requests. + + + Providing `doubleSubmitToken` does not replace or disable CSRF protection. It enables the additional Double-Submit Cookie + validation on top of the standard CSRF checks already performed by Aura Auth. + + - The return value depends on the `redirect` option: - **`redirect: true`** (default) — redirects to `redirectTo` (or the default post-update URL) via the `Location` header. - **`redirect: false`** — resolves to an object with the updated session, `redirect: false`, and `success`, letting you handle navigation yourself. @@ -88,3 +97,19 @@ const output = await api.updateSession({ redirectTo: "/dashboard", }) ``` + +### Enable Double-Submit Cookie CSRF protection + +```ts title="double-submit.ts" lineNumbers +import { api } from "@/lib/auth" + +const output = await api.updateSession({ + session: { + user: { + nickname: "newnickname", + }, + }, + headers: getHeaders(), + doubleSubmitToken: getDoubleSubmitTokenFromRequest(), +}) +``` diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 71739e77..c662ee72 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -148,7 +148,7 @@ export const createAuthAPI = ): Promise> => { assertDoubleSubmitToken(options) - return updateSession({ ctx, skipCSRFCheck: true, ...options }) + return updateSession({ ctx, ...options }) }, /** * Retrieves the access token for a specific OAuth provider on the server-side. @@ -208,7 +208,7 @@ export const createAuthAPI = , options?: RefreshUserInfoAPIOptions ): Promise> => { - assertDoubleSubmitToken(options || {}) + assertDoubleSubmitToken(options ?? {}) return refreshUserInfo(oauth, { ctx, ...options }) }, /** diff --git a/packages/core/src/api/getProviderTokens.ts b/packages/core/src/api/getProviderTokens.ts index 175716ee..b05fbb9b 100644 --- a/packages/core/src/api/getProviderTokens.ts +++ b/packages/core/src/api/getProviderTokens.ts @@ -50,7 +50,6 @@ export const getProviderTokens = async ( }, } } catch (error) { - console.error("Error in getProviderTokens:", error) const { code, message, statusCode } = handleApiError(error, "PROVIDER_TOKENS_ERROR", "Failed to get provider tokens") const headers = toUnionHeaders(initialHeaders, secureApiHeaders) diff --git a/packages/core/src/api/refreshUserInfo.ts b/packages/core/src/api/refreshUserInfo.ts index 2fe8d6bc..da305c2a 100644 --- a/packages/core/src/api/refreshUserInfo.ts +++ b/packages/core/src/api/refreshUserInfo.ts @@ -33,7 +33,7 @@ export const refreshUserInfo = async ( const { provider, headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) + .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) .buildRequest(requestInit, `/providers/${oauth}/user/refresh`) .verifyRateLimit("refreshUserInfo") .execute() diff --git a/packages/core/src/api/revokeToken.ts b/packages/core/src/api/revokeToken.ts index cff6ad7c..845e92b4 100644 --- a/packages/core/src/api/revokeToken.ts +++ b/packages/core/src/api/revokeToken.ts @@ -69,14 +69,14 @@ export const revokeToken = async ( structuredData: { provider: oauth, operation: disconnect ? "disconnect" : "revoke", - skipCSRFCheck: skipCSRFCheck || Boolean(doubleSubmitToken), + skipCSRFCheck: skipCSRFCheck && !!doubleSubmitToken, }, }) const { provider, headers, request, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) + .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) .buildRequest(requestInit, `/providers/${oauth}/tokens/revoke`) .verifyRateLimit("revokeToken") .execute() diff --git a/packages/core/src/api/signInCredentials.ts b/packages/core/src/api/signInCredentials.ts index 1d24b8eb..0d10d73c 100644 --- a/packages/core/src/api/signInCredentials.ts +++ b/packages/core/src/api/signInCredentials.ts @@ -18,7 +18,7 @@ export const signInCredentials = async ({ const { cookies, credentials, sessionStrategy, logger } = ctx try { const { request, rateLimit } = await createValidation(ctx, headerInit) - .verifyCSRFToken(skipCSRFCheck || Boolean(doubleSubmitToken)) + .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) .buildRequest(requestInit, "/signIn/credentials") .verifyRateLimit("signInCredentials") .execute() diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts index bea577e8..95ebcab3 100644 --- a/packages/core/src/api/signOut.ts +++ b/packages/core/src/api/signOut.ts @@ -11,9 +11,9 @@ export const signOut = async ({ skipCSRFCheck = false, doubleSubmitToken = undefined, }: FunctionAPIContext): Promise => { - let responseHeaders = new Headers(headersInit) + let responseHeaders = new Headers(headersInit ?? requestInit?.headers) try { - responseHeaders = await ctx.sessionStrategy.destroySession(responseHeaders, skipCSRFCheck || Boolean(doubleSubmitToken)) + responseHeaders = await ctx.sessionStrategy.destroySession(responseHeaders, skipCSRFCheck && !!doubleSubmitToken) const { request } = await createValidation(ctx, responseHeaders).buildRequest(requestInit, "/signOut").execute() const headersBuilder = new HeadersBuilder(responseHeaders) diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts index e5aa74f0..256a9580 100644 --- a/packages/core/src/api/signUp.ts +++ b/packages/core/src/api/signUp.ts @@ -18,7 +18,7 @@ export const signUp = async = Record({ doubleSubmitToken = undefined, }: FunctionAPIContext>): Promise> => { try { - console.log("double submit token:", skipCSRFCheck || Boolean(doubleSubmitToken)) const { session, headers } = await ctx.sessionStrategy.refreshSession( new Headers(headersInit), sessionInit, - skipCSRFCheck || Boolean(doubleSubmitToken) + skipCSRFCheck && !!doubleSubmitToken ) if (!session) { throw new AuraAuthError({ code: "UPDATE_SESSION_INVALID" }) @@ -63,7 +62,6 @@ export const updateSession = async ({ }, } as UpdateSessionAPIReturn } catch (error) { - console.error("Error in updateSession API:", error) const { code, message, statusCode } = handleApiError(error, "UPDATE_SESSION_INVALID", "Failed to update session.") const headers = new Headers(secureApiHeaders) diff --git a/packages/core/src/shared/utils/api.ts b/packages/core/src/shared/utils/api.ts index c70ad1dd..c6e67c77 100644 --- a/packages/core/src/shared/utils/api.ts +++ b/packages/core/src/shared/utils/api.ts @@ -167,9 +167,13 @@ export const assertDoubleSubmitToken = ( ) => { if (options.doubleSubmitToken) { options.skipCSRFCheck = false - options.headers = new Headers(options.headers) + options.headers = new Headers(options.headers ?? options.request?.headers ?? {}) options.headers.set("x-csrf-token", options.doubleSubmitToken) + options.request?.headers.set("x-csrf-token", options.doubleSubmitToken) } else { - options.skipCSRFCheck = true + if (options.skipCSRFCheck === undefined || options.skipCSRFCheck === true) { + options.skipCSRFCheck = true + options.doubleSubmitToken = "token" + } } } diff --git a/packages/core/test/api/stateless/refreshUserInfo.test.ts b/packages/core/test/api/stateless/refreshUserInfo.test.ts index 83e94a20..23bd28fa 100644 --- a/packages/core/test/api/stateless/refreshUserInfo.test.ts +++ b/packages/core/test/api/stateless/refreshUserInfo.test.ts @@ -545,4 +545,184 @@ describe("refreshUserInfo", () => { }, }) }) + + test("refreshUserInfo with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + doubleSubmitToken: csrfToken, + }) + + expect(output).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/userinfo", { + method: "GET", + headers: { + "User-Agent": `Aura Auth/${AURA_AUTH_VERSION}`, + Accept: "application/json", + Authorization: `Bearer ${oauthTokens.accessToken}`, + }, + signal: expect.any(AbortSignal), + }) + }) + + test("refreshUserInfo with doubleSubmitToken and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + doubleSubmitToken: "invalid-token", + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + test("refreshUserInfo enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/userinfo", { + method: "GET", + headers: { + "User-Agent": `Aura Auth/${AURA_AUTH_VERSION}`, + Accept: "application/json", + Authorization: `Bearer ${oauthTokens.accessToken}`, + }, + signal: expect.any(AbortSignal), + }) + }) + + test("refreshUserInfo enables double-submit cookie manually and invalid token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": "invalid-token", + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/test/api/stateless/revokeToken.test.ts b/packages/core/test/api/stateless/revokeToken.test.ts index 61b52678..f91278ec 100644 --- a/packages/core/test/api/stateless/revokeToken.test.ts +++ b/packages/core/test/api/stateless/revokeToken.test.ts @@ -452,4 +452,162 @@ describe("revokeToken", () => { }) ) }) + + test("revokeToken with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + doubleSubmitToken: csrfToken, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + const setCookieHeader = output.headers.get("set-cookie") + expect(setCookieHeader).toContain("aura-auth.access_token.oauth-provider=") + expect(setCookieHeader).toContain("Expires=") + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + }) + + test("revokeToken with doubleSubmitToken and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + doubleSubmitToken: "invalid-token", + }) + + expect(output).toEqual({ + success: false, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + const setCookieHeader = output.headers.get("set-cookie") + expect(setCookieHeader).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test("revokeToken enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + const setCookieHeader = output.headers.get("set-cookie") + expect(setCookieHeader).toContain("aura-auth.access_token.oauth-provider=") + expect(setCookieHeader).toContain("Expires=") + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + }) + + test("revokeToken enables double-submit cookie manually and invalid token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": "invalid-token", + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: false, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + const setCookieHeader = output.headers.get("set-cookie") + expect(setCookieHeader).toBeNull() + expect(mockFetch).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/test/api/stateless/signInCredentials.test.ts b/packages/core/test/api/stateless/signInCredentials.test.ts index b85ad99e..1dd2682d 100644 --- a/packages/core/test/api/stateless/signInCredentials.test.ts +++ b/packages/core/test/api/stateless/signInCredentials.test.ts @@ -222,4 +222,112 @@ describe("signInCredentials API", async () => { toResponse: expect.any(Function), }) }) + + test("signInCredentials with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const signIn = await api.signInCredentials({ + headers, + payload: { + username: "johndoe", + password: "1234567890", + }, + doubleSubmitToken: csrfToken, + }) + expect(signIn).toEqual({ + success: true, + headers: expect.any(Headers), + redirect: false, + redirectURL: null, + toResponse: expect.any(Function), + }) + const decoded = await jose.decodeJWT(getSetCookie(signIn.headers, "aura-auth.session_token")!) + expect(decoded).toMatchObject({ + sub: "1234567890", + email: "johndoe@example.com", + name: "johndoe", + image: "https://example.com/image.jpg", + }) + }) + + test("signInCredentials with doubleSubmitToken and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const signIn = await api.signInCredentials({ + headers, + payload: { + username: "johndoe", + password: "1234567890", + }, + doubleSubmitToken: "invalid-value", + }) + expect(signIn).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signInCredentials enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const newHeaders = new Headers(headers) + newHeaders.set("x-csrf-token", csrfToken) + + const signIn = await api.signInCredentials({ + headers: newHeaders, + payload: { + username: "johndoe", + password: "1234567890", + }, + skipCSRFCheck: false, + }) + expect(signIn).toEqual({ + success: true, + headers: expect.any(Headers), + redirect: false, + redirectURL: null, + toResponse: expect.any(Function), + }) + const decoded = await jose.decodeJWT(getSetCookie(signIn.headers, "aura-auth.session_token")!) + expect(decoded).toMatchObject({ + sub: "1234567890", + email: "johndoe@example.com", + name: "johndoe", + image: "https://example.com/image.jpg", + }) + }) + + test("signInCredentials enables double-submit cookie manually and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const newHeaders = new Headers(headers) + newHeaders.set("x-csrf-token", "invalid-value") + + const signIn = await api.signInCredentials({ + headers: newHeaders, + payload: { + username: "johndoe", + password: "1234567890", + }, + skipCSRFCheck: false, + }) + expect(signIn).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) }) diff --git a/packages/core/test/api/stateless/signOut.test.ts b/packages/core/test/api/stateless/signOut.test.ts index 0f51f77f..2405768d 100644 --- a/packages/core/test/api/stateless/signOut.test.ts +++ b/packages/core/test/api/stateless/signOut.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest" import { createAuth } from "@/createAuth.ts" -import { api, jose } from "@test/presets.ts" +import { api, jose, sessionPayload } from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" describe("signOut API", async () => { @@ -119,5 +119,101 @@ describe("signOut API", async () => { }) }) - test("") + test("signOut with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const sessionToken = await jose.encodeJWT(sessionPayload) + + const output = await api.signOut({ + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + doubleSubmitToken: csrfToken, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signOut with invalid doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const sessionToken = await jose.encodeJWT(sessionPayload) + + const output = await api.signOut({ + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + doubleSubmitToken: "invalid-token", + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signOut enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const sessionToken = await jose.encodeJWT(sessionPayload) + + const output = await api.signOut({ + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signOut enables double-submit cookie manually and invalid token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const sessionToken = await jose.encodeJWT(sessionPayload) + + const output = await api.signOut({ + headers: { + "X-CSRF-Token": "invalid-token", + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) }) diff --git a/packages/core/test/api/stateless/signUp.test.ts b/packages/core/test/api/stateless/signUp.test.ts index a6c4af64..f68044a5 100644 --- a/packages/core/test/api/stateless/signUp.test.ts +++ b/packages/core/test/api/stateless/signUp.test.ts @@ -211,4 +211,98 @@ describe("signUp API", async () => { toResponse: expect.any(Function), }) }) + + test("signUp with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + headers, + redirect: false, + redirectTo: "https://malicious.com/dashboard", + doubleSubmitToken: csrfToken, + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp with doubleSubmitToken with invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const output = await api.signUp({ + payload, + headers, + redirect: false, + redirectTo: "https://malicious.com/dashboard", + doubleSubmitToken: "invalid-token", + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const newHeaders = new Headers(headers) + newHeaders.set("x-csrf-token", csrfToken) + + const output = await api.signUp({ + payload, + headers: newHeaders, + redirect: false, + redirectTo: "https://malicious.com/dashboard", + skipCSRFCheck: false, + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("signUp enables double-submit cookie manually and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const newHeaders = new Headers(headers) + newHeaders.set("x-csrf-token", "invalid-value") + + const output = await api.signUp({ + payload, + headers: newHeaders, + redirect: false, + redirectTo: "https://malicious.com/dashboard", + skipCSRFCheck: false, + }) + expect(output.headers.get("Location")).toBeNull() + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) }) diff --git a/packages/core/test/api/stateless/updateSession.test.ts b/packages/core/test/api/stateless/updateSession.test.ts index 41b377b3..49bd28dc 100644 --- a/packages/core/test/api/stateless/updateSession.test.ts +++ b/packages/core/test/api/stateless/updateSession.test.ts @@ -402,4 +402,73 @@ describe("updateSession API", () => { expect(decoded.exp).not.toEqual(attackerExpiration) expect(decoded.exp).toBeLessThanOrEqual(fifteenDaysFromNow) }) + + test("updateSession with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "http://localhost:3000") + + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + }) + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { user: { name: "Alice" } }, + redirectTo: "/dashboard", + doubleSubmitToken: csrfToken, + }) + expect(updated.headers.get("Location")).toBe("/dashboard") + expect(updated).toEqual({ + session: { + user: { + sub: "1234567890", + name: "Alice", + email: "johndoe@example.com", + }, + expires: expect.any(String), + }, + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("updateSession with doubleSubmitToken and invalid value", async () => { + vi.stubEnv("BASE_URL", "http://localhost:3000") + + const sessionToken = await jose.encodeJWT({ + sub: "1234567890", + name: "John Doe", + email: "johndoe@example.com", + }) + + const csrfToken = await createCSRF(jose) + const updated = await api.updateSession({ + headers: new Headers({ + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }), + session: { user: { name: "Alice" } }, + redirectTo: "/dashboard", + doubleSubmitToken: "invalid-token", + }) + expect(updated.headers.get("Location")).toBe(null) + expect(updated).toEqual({ + success: false, + session: null, + redirect: false, + redirectURL: null, + error: { + code: "UPDATE_SESSION_INVALID", + message: "Failed to update session parameters.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) })