diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 163be2df..2bc8f77e 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -273,6 +273,19 @@ export interface SessionStrategy { * @unstable This API is experimental and may change in future releases. */ isProviderConnected(oauth: string, headers: Headers): Promise + + /** + * Refresh the user info in the session. + * @unstable This API is experimental and may change in future releases. + */ + refreshUserInfo( + user: Partial, + headers: Headers, + skipCSRFCheck?: boolean + ): Promise<{ + session: Session | null + headers: Headers + }> } /** Inputs for constructing a session strategy implementation for a given identity schema. */ diff --git a/packages/core/src/api/refreshUserInfo.ts b/packages/core/src/api/refreshUserInfo.ts index 80d28029..f5735552 100644 --- a/packages/core/src/api/refreshUserInfo.ts +++ b/packages/core/src/api/refreshUserInfo.ts @@ -1,9 +1,7 @@ -import { HeadersBuilder } from "@aura-stack/router" import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { getProviderTokens } from "./getProviderTokens.ts" import { createValidation, handleApiError } from "@/shared/utils/api.ts" -import { toUnionHeaders, getStandardSession } from "@/shared/utils.ts" import type { FunctionAPIContext, RefreshUserInfoAPIOptions, @@ -24,16 +22,16 @@ export const refreshUserInfo = async ( doubleSubmitToken = undefined, }: FunctionAPIContext ): Promise> => { - const { cookies } = ctx try { + const doubleSubmitValidation = skipCSRFCheck && !!doubleSubmitToken ctx.logger?.log("OAUTH_USERINFO_REQUEST_INITIATED", { - structuredData: { provider: oauth, skipCSRFCheck: skipCSRFCheck || Boolean(doubleSubmitToken) }, + structuredData: { provider: oauth, skipCSRFCheck: doubleSubmitValidation }, }) const { provider, headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() - .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) + .verifyCSRFToken(doubleSubmitValidation) .buildRequest(requestInit, `/providers/${oauth}/user/refresh`) .verifyRateLimit("refreshUserInfo") .execute() @@ -78,21 +76,14 @@ export const refreshUserInfo = async ( structuredData: { provider: oauth, userId: userInfo.sub }, }) - const sessionToken = await ctx.sessionStrategy.createSession(userInfo) - const newHeaders = new HeadersBuilder(headers) - .setCookie(cookies.sessionToken.name, sessionToken, cookies.sessionToken.attributes) - .toHeaders() - - const mergedHeaders = toUnionHeaders(newHeaders, headers) - - const session = await getStandardSession({ - sessionToken, - jwt: ctx.jwtManager, - identity: ctx.identity, - }) + const { session, headers: newHeaders } = await ctx.sessionStrategy.refreshUserInfo( + userInfo, + headers, + doubleSubmitValidation + ) return { success: !!session, - headers: mergedHeaders, + headers: newHeaders, session: session, toResponse: () => { return Response.json( @@ -100,11 +91,12 @@ export const refreshUserInfo = async ( success: !!session, session, }, - { headers: mergedHeaders, status: 200 } + { headers: newHeaders, status: 200 } ) }, } as RefreshUserInfoAPIReturn } catch (error) { + console.warn("Error refreshing user info:", error) const { code, message, statusCode } = handleApiError( error, "UNKNOWN_REFRESH_USER_INFO_ERROR", diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts index 0e239710..09daca97 100644 --- a/packages/core/src/session/stateful.ts +++ b/packages/core/src/session/stateful.ts @@ -1041,6 +1041,10 @@ export const createStatefulStrategy = ({ } } + const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers, skipCSRFCheck: boolean) => { + return await refreshSession(headers, { user: userInfo }, skipCSRFCheck) + } + return { getSession, createSession, @@ -1050,5 +1054,6 @@ export const createStatefulStrategy = ({ destroySession, getProviderTokens, isProviderConnected, + refreshUserInfo, } } diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index eb8382ef..6e909a74 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -6,7 +6,14 @@ import { handleApiError } from "@/shared/utils/api.ts" import { createJoseManager } from "@/session/jose-manager.ts" import { createCookieManager } from "@/session/cookie-manager.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" -import { verifyCSRFToken, getErrorName, verifySessionToken, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts" +import { + verifyCSRFToken, + getErrorName, + verifySessionToken, + shouldRefresh, + toUnionHeaders, + getStandardSession, +} from "@/shared/utils.ts" import type { Session, SessionStrategy, @@ -381,6 +388,22 @@ export const createStatelessStrategy = ({ } } + const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers) => { + const sessionToken = await createSession(userInfo) + + const newHeaders = new HeadersBuilder(headers) + .setCookie(cookies().sessionToken.name, sessionToken, cookies().sessionToken.attributes) + .toHeaders() + + const session = await getStandardSession({ + jwt, + identity, + sessionToken, + }) + const mergedHeaders = toUnionHeaders(newHeaders, secureApiHeaders) + return { session, headers: mergedHeaders } + } + // JWT strategy: stateless tokens cannot be revoked server-side const revokeSession = async (_sessionId: string): Promise => {} @@ -398,6 +421,7 @@ export const createStatelessStrategy = ({ revokeSession, revokeToken, isProviderConnected, + refreshUserInfo, destroySession, } } diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 20b29c62..924d8cec 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -236,7 +236,7 @@ export const getStandardSession = async ({ if (!userClaims.sub) return null return { user: userClaims, - expires: exp, + expires: new Date(exp * 1000).toISOString(), } } diff --git a/packages/core/test/actions/providers/user/refresh/stateful.test.ts b/packages/core/test/actions/providers/user/refresh/stateful.test.ts new file mode 100644 index 00000000..c3e2ebd4 --- /dev/null +++ b/packages/core/test/actions/providers/user/refresh/stateful.test.ts @@ -0,0 +1,933 @@ +import { describe, test, expect, vi } from "vitest" +import { + authInstance, + jose, + oauthAccountEntity, + oauthCustomService, + oauthTokens, + sessionEntityWithUser, + userEntity, +} from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { AURA_AUTH_VERSION } from "@/shared/utils.ts" +import { createSchemaRegistry } from "@/validator/registry.ts" + +describe("refreshUserInfo action", () => { + test("unsupported oauth provider", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const response = await POST(new Request("https://example.com/auth/unsupported/user/refresh", { method: "POST" })) + expect(await response.json()).toEqual({ + code: "NOT_FOUND", + type: "ROUTER_FLOW", + message: "The requested route address cannot be found or is unavailable on this application endpoint server context.", + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is missing", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + Cookie: `aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is invalid", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, + "X-CSRF-Token": "invalid-token", + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("throws error when provider token does not exist", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("successfully refreshes user info", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + }) + expect(response.status).toBe(200) + 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), + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles getUserInfo invalid response from provider", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("handles getUserInfo OAuth error response", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + error: "invalid_token", + error_description: "The access token expired", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("handles getUserInfo missing required user fields", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const updateUserMock = vi.fn() + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + email: "john@example.com", + name: "John Doe", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("handles getProviderTokens failure", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + }) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { refreshToken: _, ...spread } = oauthCustomService + + const { + handlers: { POST }, + } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }, + { oauth: [spread] } + ) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("handles expired access token with successful refresh", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const expiredOAuthAccount = { + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + } + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(expiredOAuthAccount) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => oauthTokens, + }) + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).toHaveBeenCalledWith("oauth-provider", { + accountId: "account-123", + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles invalid user info response with missing content type", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ + "Content-Type": "text/plain", + }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + + expect(spyParse).not.toHaveBeenCalled() + expect(spyParseAsPartial).not.toHaveBeenCalled() + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + expect(updateSessionMock).not.toHaveBeenCalled() + expect(touchSessionMock).not.toHaveBeenCalled() + }) + + test("successfully refreshes with custom profile function", async () => { + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + nickname: "johnny", + email_verified: true, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-profile/user/refresh", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, "valid-token-hash") + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-profile") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, "valid-token-hash") + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + nickname: "johnny", + email_verified: true, + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) +}) diff --git a/packages/core/test/actions/providers/user/stateless.test.ts b/packages/core/test/actions/providers/user/refresh/stateless.test.ts similarity index 90% rename from packages/core/test/actions/providers/user/stateless.test.ts rename to packages/core/test/actions/providers/user/refresh/stateless.test.ts index 2ecbd669..89460337 100644 --- a/packages/core/test/actions/providers/user/stateless.test.ts +++ b/packages/core/test/actions/providers/user/refresh/stateless.test.ts @@ -90,8 +90,10 @@ describe("refreshUserInfo action", () => { ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, - email: "john.updated@example.com", + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -110,10 +112,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, - email: "john.updated@example.com", + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) expect(response.status).toBe(200) @@ -293,8 +297,9 @@ describe("refreshUserInfo action", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - ...sessionPayload, - email: "john.updated@example.com", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -313,10 +318,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, - email: "john.updated@example.com", + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -387,9 +394,10 @@ describe("refreshUserInfo action", () => { ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, id: "1234567890", - email: "john.updated@example.com", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -408,10 +416,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, - email: "john.updated@example.com", + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) expect(response.headers.get("set-cookie")).toContain("aura-auth.session_token=") @@ -448,9 +458,10 @@ describe("refreshUserInfo action", () => { ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, id: "1234567890", - email: "john.updated@example.com", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", nickname: "johnny", email_verified: true, }), @@ -471,10 +482,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, - email: "john.updated@example.com", + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) }) @@ -490,8 +503,10 @@ describe("refreshUserInfo action", () => { ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, - email: "john.updated@example.com", + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -510,10 +525,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, - email: "john.updated@example.com", + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) }) diff --git a/packages/core/test/api/stateful/getAccessToken.test.ts b/packages/core/test/api/stateful/getAccessToken.test.ts new file mode 100644 index 00000000..130aed68 --- /dev/null +++ b/packages/core/test/api/stateful/getAccessToken.test.ts @@ -0,0 +1,575 @@ +import { describe, test, expect, vi } from "vitest" +import { authInstance, jose, oauthAccountEntity, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { createAuth } from "@/createAuth.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" +import type { OAuthProviderConfig } from "@/@types/oauth.ts" + +describe("getAccessToken API (Stateful)", () => { + test("throws error when provider 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.getAccessToken("unsuppported", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + accessToken: null, + error: { + code: "UNSUPPORTED_OAUTH_CONFIGURATION", + message: "The targeted OAuth provider has not been configured in the initialization parameters.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when session token is missing", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const output = await api.getAccessToken("oauth-provider", { + headers: new Headers({ + Cookie: "aura-auth.session_token=invalid-token", + }), + }) + expect(output).toEqual({ + success: false, + accessToken: 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).toHaveBeenCalledOnce() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when session is not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + accessToken: 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).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("throws error when OAuth account does not exist", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(null) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + accessToken: null, + error: { + code: "COOKIE_INVALID_VALUE", + message: "Expected configuration cookie not found or contains an empty value.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("successfully gets provider tokens from database", async () => { + vi.stubEnv("BASE_URL", "http://localhost:3000") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + accessToken: "access-token", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken config not provided", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + }) + const updateOAuthTokensMock = vi.fn() + + const { refreshToken: _, ...spread } = oauthCustomService + const { api } = createAuth({ + oauth: [spread], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + accessToken: null, + error: { + code: "OAUTH_INVALID_REFRESH_TOKEN_CONFIG", + message: + "Internal library configuration error. Token refresh operations are not enabled or configured for this identity provider.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + + test("refreshToken successfully refreshes tokens", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + accessToken: "new-access-token", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: createBasicAuthHeader("oauth_client_id", "oauth_client_secret"), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken successfully refreshes tokens with credentials auth in refreshToken config", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + + const provider: OAuthProviderConfig = { + ...oauthCustomService, + refreshToken: { + url: "https://example.com/oauth/refresh_token", + authorization: { type: "credentials" }, + }, + } + + const { api } = createAuth({ + oauth: [provider], + session: { + strategy: "database", + adapter: { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + } as any, + }, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + accessToken: "new-access-token", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/refresh_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + client_id: "oauth_client_id", + client_secret: "oauth_client_secret", + }), + signal: expect.any(AbortSignal), + }) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) + + test("refreshToken fails when OAuth provider returns an error", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant", error_description: "Refresh token revoked" }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + accessToken: null, + error: { + code: "OAUTH_INVALID_REFRESH_TOKEN_RESPONSE", + message: "Your secure session renewal failed. Please sign in again to continue.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("refreshToken handles unexpected network exceptions gracefully", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + accessToken: null, + error: { + code: "PROVIDER_TOKENS_ERROR", + message: "Failed to get provider tokens", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + test("returns current tokens without refreshing when close to expiry but outside the refresh window", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date((currentTime + 600) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + }) + const updateOAuthTokensMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + const mockFetch = vi.fn() + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + accessToken: "access-token", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + }) + + 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 currentTime = Math.floor(Date.now() / 1000) + const getOAuthAccountMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessTokenExpiresAt: new Date((currentTime + 120) * 1000), + refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), + }) + const updateOAuthTokensMock = vi.fn().mockResolvedValue({ + ...oauthAccountEntity, + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "brand-new-refreshed-token", + refresh_token: "new-refresh-token", + id_token: "new-id-token", + expires_in: 3600, + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.getAccessToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, + }, + }) + + expect(output).toEqual({ + success: true, + accessToken: "brand-new-refreshed-token", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(updateOAuthTokensMock).toHaveBeenCalledWith( + "oauth-provider", + expect.objectContaining({ + accountId: "account-123", + accessToken: "brand-new-refreshed-token", + refreshToken: "new-refresh-token", + idToken: "new-id-token", + }) + ) + }) +}) diff --git a/packages/core/test/api/stateful/getProviderTokens.test.ts b/packages/core/test/api/stateful/getProviderTokens.test.ts index 6318c3c3..e1f819c9 100644 --- a/packages/core/test/api/stateful/getProviderTokens.test.ts +++ b/packages/core/test/api/stateful/getProviderTokens.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi } from "vitest" -import { authInstance, jose, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" +import { authInstance, jose, oauthAccountEntity, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" import { createAuth } from "@/createAuth.ts" import { createBasicAuthHeader } from "@/shared/utils.ts" @@ -142,18 +142,7 @@ describe("getProviderTokens API (Stateful)", () => { vi.stubEnv("BASE_URL", "http://localhost:3000") const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) - const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), - }) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) const updateOAuthTokensMock = vi.fn() const { api } = authInstance({ @@ -193,16 +182,9 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn() @@ -246,28 +228,15 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn().mockResolvedValue({ - accountId: "account-123", + ...oauthAccountEntity, accessToken: "new-access-token", refreshToken: "new-refresh-token", idToken: "new-id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const { api } = authInstance({ @@ -340,28 +309,15 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn().mockResolvedValue({ - accountId: "account-123", + ...oauthAccountEntity, accessToken: "new-access-token", refreshToken: "new-refresh-token", idToken: "new-id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const provider: OAuthProviderConfig = { @@ -449,16 +405,9 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn() @@ -503,16 +452,9 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), refreshTokenExpiresAt: new Date(Date.now() + 7200 * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn() @@ -554,16 +496,9 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const currentTime = Math.floor(Date.now() / 1000) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date((currentTime + 600) * 1000), refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn() @@ -605,28 +540,15 @@ describe("getProviderTokens API (Stateful)", () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const currentTime = Math.floor(Date.now() / 1000) const getOAuthAccountMock = vi.fn().mockResolvedValue({ - accountId: "account-123", - accessToken: "access-token", - refreshToken: "refresh-token", - idToken: "id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", + ...oauthAccountEntity, accessTokenExpiresAt: new Date((currentTime + 120) * 1000), refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), - updatedAt: new Date(), }) const updateOAuthTokensMock = vi.fn().mockResolvedValue({ - accountId: "account-123", + ...oauthAccountEntity, accessToken: "brand-new-refreshed-token", refreshToken: "new-refresh-token", idToken: "new-id-token", - tokenType: "Bearer", - scopes: "scope1 scope2", - issuer: "https://example.com", - accessTokenExpiresAt: new Date((currentTime + 3600) * 1000), - refreshTokenExpiresAt: new Date((currentTime + 7200) * 1000), - updatedAt: new Date(), }) const { api } = authInstance({ diff --git a/packages/core/test/api/stateful/refreshUserInfo.test.ts b/packages/core/test/api/stateful/refreshUserInfo.test.ts new file mode 100644 index 00000000..23c5821e --- /dev/null +++ b/packages/core/test/api/stateful/refreshUserInfo.test.ts @@ -0,0 +1,1257 @@ +import { describe, test, expect, vi } from "vitest" +import { authInstance, jose, oauthAccountEntity, sessionEntityWithUser, userEntity } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { createSchemaRegistry } from "@/validator/registry.ts" + +describe("refreshUserInfo API (Stateful)", () => { + test("throws error when provider is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const output = await api.refreshUserInfo("unsupported", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "UNSUPPORTED_OAUTH_CONFIGURATION", + message: "The targeted OAuth provider has not been configured in the initialization parameters.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getUserByIdMock).not.toHaveBeenCalled() + expect(createUserMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + }) + + test("throws error when session token is missing", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: new Headers({ + Cookie: "aura-auth.session_token=invalid-token", + }), + }) + expect(output).toEqual({ + success: false, + session: 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).toHaveBeenCalledOnce() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getUserByIdMock).not.toHaveBeenCalled() + expect(createUserMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + }) + + test("throws error when session is not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + session: 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).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getUserByIdMock).not.toHaveBeenCalled() + expect(createUserMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is missing", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const sessionToken = "valid-session-token" + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + session: 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).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getUserByIdMock).not.toHaveBeenCalled() + expect(createUserMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + }) + + test("throws error when OAuth account does not exist", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(null) + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "INVALID_ACCESS_TOKEN_RETRIEVING_REFRESH_USER_INFO", + message: "Failed to sync profile data. Your active session access token is missing or invalid.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getUserByIdMock).not.toHaveBeenCalled() + expect(createUserMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(updateUserMock).not.toHaveBeenCalled() + }) + + test("successfully refreshes user info", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.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}`, + }, + }) + + expect(output).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + /** + * @todo Optimize the session token verification across multiple calls. + */ + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles getUserInfo network error gracefully", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "UNKNOWN_OAUTH_USER_INFO_ERROR", + message: "Failed to communicate clean state down to the user configuration data provider.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("handles getUserInfo invalid response from provider", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + }) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "INVALID_OAUTH_USER_INFO_RESPONSE", + message: "The resource userInfo target server returned an error code response.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("handles getUserInfo OAuth error response", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + error: "invalid_token", + error_description: "The access token expired", + }), + }) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "INVALID_OAUTH_USER_INFO_RES_FORMAT", + message: "The returned user info profile structure payload is corrupted or unexpected.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("handles getUserInfo missing required user fields", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + email: "john@example.com", + name: "John Doe", + }), + }) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "INVALID_USER_INFO", + message: "The provider profile identity map did not supply an immutable index key (id/sub/uid).", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("toResponse returns correct response on failure", async () => { + const { api } = authInstance({}) + const output = await api.refreshUserInfo("unsupported") + + const response = output.toResponse() + expect(response.status).toBe(400) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles expired access token with successful refresh", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const expiredOAuthAccount = { + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + } + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(expiredOAuthAccount) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + token_type: "Bearer", + }), + }) + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.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}`, + }, + }) + + expect(output).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).toHaveBeenCalledWith("oauth-provider", { + accountId: "account-123", + accessToken: "new-access-token", + refreshToken: "new-refresh-token", + idToken: "id-token", + tokenType: "Bearer", + scopes: "scope1 scope2", + accessTokenExpiresAt: expect.any(Date), + refreshTokenExpiresAt: expect.any(Date), + }) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles invalid user info response with missing content type", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + headers: { + get: (name: string) => (name === "content-type" ? "text/html" : null), + }, + json: async () => ({ + id: "1234567890", + email: "john@example.com", + }), + }) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "OAUTH_INVALID_CONTENT_TYPE", + message: + "The identity provider returned an unreadable response format. Please try again or check the provider status.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("handles session token verification failure", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const invalidSessionToken = "invalid.session.token" + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${invalidSessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: 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), + }) + }) + + test("toResponse returns correct response on success", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.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}`, + }, + }) + + const response = output.toResponse() + expect(response.status).toBe(200) + + const json = await response.json() + expect(json).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles token refresh failure", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const expiredOAuthAccount = { + ...oauthAccountEntity, + accessTokenExpiresAt: new Date(Date.now() - 3600 * 1000), + } + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(expiredOAuthAccount) + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const createUserMock = vi.fn() + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ + error: "invalid_grant", + error_description: "Refresh token expired", + }), + }) + 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}`, + }, + }) + + expect(output).toEqual({ + success: false, + session: null, + error: { + code: "INVALID_ACCESS_TOKEN_RETRIEVING_REFRESH_USER_INFO", + message: "Failed to sync profile data. Your active session access token is missing or invalid.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + vi.unstubAllGlobals() + }) + + test("handles doubleSubmitToken parameter", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + doubleSubmitToken: csrfToken, + }) + + expect(output).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) + + test("handles invalid doubleSubmitToken parameter", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateOAuthTokensMock = vi.fn() + const getUserByIdMock = vi.fn() + const createUserMock = vi.fn() + const createSessionMock = vi.fn() + const updateUserMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateOAuthTokens: updateOAuthTokensMock, + getUserById: getUserByIdMock, + createUser: createUserMock, + createSession: createSessionMock, + updateUser: updateUserMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + 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(getOAuthAccountMock).not.toHaveBeenCalled() + }) + + test("handles skipCSRFCheck parameter", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spyParse = vi.spyOn(registry, "parse") + const spyParseAsPartial = vi.spyOn(registry, "parseAsPartial") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const revokeSessionMock = vi.fn() + const updateOAuthTokensMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateUserMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) + const updateSessionMock = vi.fn() + const touchSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + revokeSession: revokeSessionMock, + updateOAuthTokens: updateOAuthTokensMock, + updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, + }) + + const sessionToken = "valid-session-token" + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValue({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + skipCSRFCheck: true, + }) + + expect(output).toEqual({ + success: true, + session: { + user: { + sub: "user-123", + name: "John Doe Updated", + email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", + }, + expires: expect.any(String), + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(1, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(2, sessionToken) + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(3, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateOAuthTokensMock).not.toHaveBeenCalled() + expect(getSessionByTokenMock).toHaveBeenNthCalledWith(4, sessionToken) + expect(revokeSessionMock).not.toHaveBeenCalled() + + const { attributes, ...spreadUser } = userEntity + expect(spyParse).toHaveBeenNthCalledWith(1, { + ...spreadUser, + ...attributes, + sub: "user-123", + }) + expect(spyParseAsPartial).toHaveBeenCalledWith({ + sub: "1234567890", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(spyParse).toHaveBeenNthCalledWith(2, { + sub: "user-123", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + expect(updateUserMock).toHaveBeenCalledWith("user-123", { + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", + }) + + expect(updateSessionMock).toHaveBeenCalledWith("session-123", { + id: expect.any(String), + userId: "user-123", + deviceId: null, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) + }) +}) diff --git a/packages/core/test/api/stateless/refreshUserInfo.test.ts b/packages/core/test/api/stateless/refreshUserInfo.test.ts index eabca137..693a2852 100644 --- a/packages/core/test/api/stateless/refreshUserInfo.test.ts +++ b/packages/core/test/api/stateless/refreshUserInfo.test.ts @@ -111,7 +111,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -344,7 +344,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -447,7 +447,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -511,7 +511,7 @@ describe("refreshUserInfo", () => { success: true, session: { user: sessionPayload, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -553,7 +553,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) }) @@ -590,7 +590,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function), @@ -677,7 +677,7 @@ describe("refreshUserInfo", () => { ...sessionPayload, email: "john.updated@example.com", }, - expires: expect.any(Number), + expires: expect.any(String), }, headers: expect.any(Headers), toResponse: expect.any(Function),