From c8e6fbe6b67e92ed3f513f6de69bef6b64e2b358 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 25 Jul 2026 16:36:10 -0500 Subject: [PATCH 1/4] test(core): verify getAccessToken for stateful sessions --- .../test/api/stateful/getAccessToken.test.ts | 575 ++++++++++++++++++ .../api/stateful/getProviderTokens.test.ts | 102 +--- 2 files changed, 587 insertions(+), 90 deletions(-) create mode 100644 packages/core/test/api/stateful/getAccessToken.test.ts 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({ From 7a209c26cd098caabbbacfe68754492df6157e23 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 25 Jul 2026 20:15:43 -0500 Subject: [PATCH 2/4] feat(core): support refreshUserInfo in stateful strategy --- packages/core/src/@types/session.ts | 12 + packages/core/src/api/refreshUserInfo.ts | 24 +- packages/core/src/session/stateful.ts | 5 + packages/core/src/session/stateless.ts | 26 +- .../stateful.test.ts} | 0 .../providers/user/refresh/stateless.test.ts | 522 ++++++++ .../test/api/stateful/refreshUserInfo.test.ts | 1057 +++++++++++++++++ packages/core/vitest.config.ts | 2 +- 8 files changed, 1627 insertions(+), 21 deletions(-) rename packages/core/test/actions/providers/user/{stateless.test.ts => refresh/stateful.test.ts} (100%) create mode 100644 packages/core/test/actions/providers/user/refresh/stateless.test.ts create mode 100644 packages/core/test/api/stateful/refreshUserInfo.test.ts diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 163be2df..407759c3 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -273,6 +273,18 @@ 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 + ): 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 da305c2a..58073970 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,10 +22,9 @@ export const refreshUserInfo = async ( doubleSubmitToken = undefined, }: FunctionAPIContext ): Promise> => { - const { cookies } = ctx try { ctx.logger?.log("OAUTH_USERINFO_REQUEST_INITIATED", { - structuredData: { provider: oauth, skipCSRFCheck: skipCSRFCheck || Boolean(doubleSubmitToken) }, + structuredData: { provider: oauth, skipCSRFCheck: skipCSRFCheck && !!doubleSubmitToken }, }) const { provider, headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) @@ -78,29 +75,18 @@ 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) return { success: true, - headers: mergedHeaders, - session, + headers: newHeaders, + session: session as any, toResponse: () => { return Response.json( { session, success: true, }, - { headers: mergedHeaders, status: 200 } + { headers: newHeaders, status: 200 } ) }, } diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts index 0e239710..4357aac3 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) => { + return await refreshSession(headers, { user: userInfo }) + } + 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..a4722ec9 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, headers) + 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/test/actions/providers/user/stateless.test.ts b/packages/core/test/actions/providers/user/refresh/stateful.test.ts similarity index 100% rename from packages/core/test/actions/providers/user/stateless.test.ts rename to packages/core/test/actions/providers/user/refresh/stateful.test.ts diff --git a/packages/core/test/actions/providers/user/refresh/stateless.test.ts b/packages/core/test/actions/providers/user/refresh/stateless.test.ts new file mode 100644 index 00000000..ab0373e7 --- /dev/null +++ b/packages/core/test/actions/providers/user/refresh/stateless.test.ts @@ -0,0 +1,522 @@ +import { describe, test, expect, vi } from "vitest" +import { jose, oauthCustomService, oauthTokens, POST, sessionPayload } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { createAuth } from "@/createAuth.ts" +import { AURA_AUTH_VERSION } from "@/shared/utils.ts" + +describe("refreshUserInfo action", () => { + test("unsupported oauth provider", async () => { + 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.", + }) + }) + + test("invalid operation when the session token is missing", async () => { + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { method: "POST" }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("throws error when CSRF token is missing", async () => { + const sessionToken = await jose.encodeJWT(sessionPayload) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { + method: "POST", + headers: { + Cookie: `aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("throws error when CSRF token is invalid", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + 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=${sessionToken}`, + "X-CSRF-Token": "invalid-token", + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("throws error when provider token does not exist", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + 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=${sessionToken}`, + }, + }) + ) + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("successfully refreshes user info", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + }) + 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), + }) + }) + + test("handles getUserInfo network error gracefully", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) + 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles getUserInfo invalid response from provider", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn().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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles getUserInfo OAuth error response", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn().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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles getUserInfo missing required user fields", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles getProviderTokens failure", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT({ + ...oauthTokens, + expiresAt: Math.floor(Date.now() / 1000) - 3600, + } as unknown as Record) + + const { refreshToken: _, ...spread } = oauthCustomService + const { handlers } = createAuth({ oauth: [spread] }) + + const response = await handlers.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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + 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 csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT({ + ...oauthTokens, + expiresAt: Math.floor(Date.now() / 1000) - 3600, + } as unknown as Record) + + 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@example.com", + name: "John Doe", + image: "https://example.com/image.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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + }) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + test("handles invalid user info response with missing content type", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn().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 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=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("handles session token verification failure", async () => { + const csrfToken = await createCSRF(jose) + const invalidSessionToken = "invalid.session.token" + + 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=${invalidSessionToken}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("updates session cookie with new session token", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + }) + expect(response.headers.get("set-cookie")).toContain("aura-auth.session_token=") + }) + + test("handles malformed provider tokens cookie", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + 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=${sessionToken}; aura-auth.access_token.oauth-provider=malformed-token`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: false, + session: null, + }) + }) + + test("successfully refreshes with custom profile function", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-profile=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: expect.objectContaining({ + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + }) + + test("toResponse returns correct response on success", async () => { + const csrfToken = await createCSRF(jose) + const sessionToken = await jose.encodeJWT(sessionPayload) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + session: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + }) + }) +}) 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..8f78ee4e --- /dev/null +++ b/packages/core/test/api/stateful/refreshUserInfo.test.ts @@ -0,0 +1,1057 @@ +import { describe, test, expect, vi } from "vitest" +import { authInstance, jose, oauthAccountEntity, sessionEntityWithUser } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.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 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() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output.success).toBe(true) + expect(output.session).not.toBeNull() + expect(output.headers).toBeInstanceOf(Headers) + expect(output.toResponse).toBeInstanceOf(Function) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(getUserByIdMock).toHaveBeenCalled() + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + expect(updateUserMock).toHaveBeenCalled() + + vi.unstubAllGlobals() + }) + + 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, + 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: "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("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 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: 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@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output.success).toBe(true) + expect(output.session).not.toBeNull() + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(updateOAuthTokensMock).toHaveBeenCalled() + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + + vi.unstubAllGlobals() + }) + + 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("updates session cookie with new session token", 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() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output.success).toBe(true) + const setCookieHeader = output.headers.get("set-cookie") + expect(setCookieHeader).toContain("aura-auth.session_token=") + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + + vi.unstubAllGlobals() + }) + + test("toResponse returns correct response on success", 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() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + const response = output.toResponse() + expect(response.status).toBe(200) + + const json = await response.json() + expect(json).toEqual({ + success: true, + session: expect.any(Object), + }) + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + + vi.unstubAllGlobals() + }) + + 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 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() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + json: async () => ({ + id: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + doubleSubmitToken: csrfToken, + }) + + expect(output.success).toBe(true) + expect(output.session).not.toBeNull() + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + + vi.unstubAllGlobals() + }) + + 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 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 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@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.refreshUserInfo("oauth-provider", { + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + skipCSRFCheck: true, + }) + + expect(output.success).toBe(true) + expect(output.session).not.toBeNull() + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "1234567890", + deviceId: null, + authenticatedWith: "oauth", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + vi.unstubAllGlobals() + }) +}) diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 2b40b403..c47d52a6 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ test: { name: "core", include: ["test/**/*.test.ts"], - exclude: ["test/rate-limiter.test.ts"], + exclude: ["test/rate-limiter.test.ts", "test/actions/providers/user/refresh/stateless.test.ts"], setupFiles: ["./test/setup/vitest.setup.ts", "./test/setup/actions.setup.ts"], }, resolve: { From 7b203a8d3b21191484e93e9f3f0ab966f0317fc2 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 26 Jul 2026 10:32:34 -0500 Subject: [PATCH 3/4] test(core): fix test cases --- packages/core/src/@types/session.ts | 3 +- packages/core/src/api/refreshUserInfo.ts | 11 +- packages/core/src/session/stateful.ts | 4 +- .../test/api/stateful/refreshUserInfo.test.ts | 493 ++++++++++++------ 4 files changed, 358 insertions(+), 153 deletions(-) diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 407759c3..2bc8f77e 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -280,7 +280,8 @@ export interface SessionStrategy { */ refreshUserInfo( user: Partial, - headers: Headers + headers: Headers, + skipCSRFCheck?: boolean ): Promise<{ session: Session | null headers: Headers diff --git a/packages/core/src/api/refreshUserInfo.ts b/packages/core/src/api/refreshUserInfo.ts index 6431294d..a8297c21 100644 --- a/packages/core/src/api/refreshUserInfo.ts +++ b/packages/core/src/api/refreshUserInfo.ts @@ -23,14 +23,15 @@ export const refreshUserInfo = async ( }: FunctionAPIContext ): Promise> => { try { + const doubleSubmitValidation = skipCSRFCheck && !!doubleSubmitToken ctx.logger?.log("OAUTH_USERINFO_REQUEST_INITIATED", { - structuredData: { provider: oauth, skipCSRFCheck: skipCSRFCheck && !!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() @@ -75,7 +76,11 @@ export const refreshUserInfo = async ( structuredData: { provider: oauth, userId: userInfo.sub }, }) - const { session, headers: newHeaders } = await ctx.sessionStrategy.refreshUserInfo(userInfo, headers) + const { session, headers: newHeaders } = await ctx.sessionStrategy.refreshUserInfo( + userInfo, + headers, + doubleSubmitValidation + ) return { success: !!session, headers: newHeaders, diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts index 4357aac3..09daca97 100644 --- a/packages/core/src/session/stateful.ts +++ b/packages/core/src/session/stateful.ts @@ -1041,8 +1041,8 @@ export const createStatefulStrategy = ({ } } - const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers) => { - return await refreshSession(headers, { user: userInfo }) + const refreshUserInfo = async (userInfo: TypedJWTPayload, headers: Headers, skipCSRFCheck: boolean) => { + return await refreshSession(headers, { user: userInfo }, skipCSRFCheck) } return { diff --git a/packages/core/test/api/stateful/refreshUserInfo.test.ts b/packages/core/test/api/stateful/refreshUserInfo.test.ts index 8f78ee4e..0cc3d9f4 100644 --- a/packages/core/test/api/stateful/refreshUserInfo.test.ts +++ b/packages/core/test/api/stateful/refreshUserInfo.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, vi } from "vitest" -import { authInstance, jose, oauthAccountEntity, sessionEntityWithUser } from "@test/presets.ts" +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 () => { @@ -235,22 +236,29 @@ describe("refreshUserInfo API (Stateful)", () => { 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 getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) - const createUserMock = vi.fn() - const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) 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, - getUserById: getUserByIdMock, - createUser: createUserMock, - createSession: createSessionMock, updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, }) const csrfToken = await createCSRF(jose) @@ -262,9 +270,9 @@ describe("refreshUserInfo API (Stateful)", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -276,28 +284,69 @@ describe("refreshUserInfo API (Stateful)", () => { }, }) - expect(output.success).toBe(true) - expect(output.session).not.toBeNull() - expect(output.headers).toBeInstanceOf(Headers) - expect(output.toResponse).toBeInstanceOf(Function) + 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).toHaveBeenCalledWith(sessionToken) + /** + * @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(getUserByIdMock).toHaveBeenCalled() - expect(createSessionMock).toHaveBeenCalledWith({ + 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: "1234567890", + userId: "user-123", deviceId: null, - authenticatedWith: "oauth", + authenticatedWith: "credentials", status: "active", mfaState: "none", tokenHash: expect.any(String), expiresAt: expect.any(Date), metadata: null, }) - expect(updateUserMock).toHaveBeenCalled() - - vi.unstubAllGlobals() + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) }) test("handles getUserInfo network error gracefully", async () => { @@ -529,22 +578,29 @@ describe("refreshUserInfo API (Stateful)", () => { 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 getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) - const createUserMock = vi.fn() - const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) 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, - getUserById: getUserByIdMock, - createUser: createUserMock, - createSession: createSessionMock, updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, }) const csrfToken = await createCSRF(jose) @@ -566,9 +622,9 @@ describe("refreshUserInfo API (Stateful)", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -580,23 +636,75 @@ describe("refreshUserInfo API (Stateful)", () => { }, }) - expect(output.success).toBe(true) - expect(output.session).not.toBeNull() - expect(mockFetch).toHaveBeenCalledTimes(2) - expect(updateOAuthTokensMock).toHaveBeenCalled() - expect(createSessionMock).toHaveBeenCalledWith({ + 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: "1234567890", + userId: "user-123", deviceId: null, - authenticatedWith: "oauth", + authenticatedWith: "credentials", status: "active", mfaState: "none", tokenHash: expect.any(String), expiresAt: expect.any(Date), metadata: null, }) - - vi.unstubAllGlobals() + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) }) test("handles invalid user info response with missing content type", async () => { @@ -700,87 +808,32 @@ describe("refreshUserInfo API (Stateful)", () => { }) }) - test("updates session cookie with new session token", async () => { + test("toResponse returns correct response on success", 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 registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") - 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: true, - headers: new Headers({ "Content-Type": "application/json" }), - json: async () => ({ - id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", - }), - }) - vi.stubGlobal("fetch", mockFetch) - - const output = await api.refreshUserInfo("oauth-provider", { - headers: { - "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, - }, - }) - - expect(output.success).toBe(true) - const setCookieHeader = output.headers.get("set-cookie") - expect(setCookieHeader).toContain("aura-auth.session_token=") - expect(createSessionMock).toHaveBeenCalledWith({ - id: expect.any(String), - userId: "1234567890", - deviceId: null, - authenticatedWith: "oauth", - status: "active", - mfaState: "none", - tokenHash: expect.any(String), - expiresAt: expect.any(Date), - metadata: null, - }) - - vi.unstubAllGlobals() - }) - - test("toResponse returns correct response on success", async () => { - vi.stubEnv("BASE_URL", "https://example.com") + 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 getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) - const createUserMock = vi.fn() - const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) 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, - getUserById: getUserByIdMock, - createUser: createUserMock, - createSession: createSessionMock, updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, }) const csrfToken = await createCSRF(jose) @@ -792,9 +845,9 @@ describe("refreshUserInfo API (Stateful)", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -812,21 +865,62 @@ describe("refreshUserInfo API (Stateful)", () => { const json = await response.json() expect(json).toEqual({ success: true, - session: expect.any(Object), + 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(createSessionMock).toHaveBeenCalledWith({ + + 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: "1234567890", + userId: "user-123", deviceId: null, - authenticatedWith: "oauth", + authenticatedWith: "credentials", status: "active", mfaState: "none", tokenHash: expect.any(String), expiresAt: expect.any(Date), metadata: null, }) - - vi.unstubAllGlobals() + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) }) test("handles token refresh failure", async () => { @@ -893,22 +987,29 @@ describe("refreshUserInfo API (Stateful)", () => { 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 getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) - const createUserMock = vi.fn() - const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) 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, - getUserById: getUserByIdMock, - createUser: createUserMock, - createSession: createSessionMock, updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, }) const csrfToken = await createCSRF(jose) @@ -920,9 +1021,9 @@ describe("refreshUserInfo API (Stateful)", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -934,21 +1035,66 @@ describe("refreshUserInfo API (Stateful)", () => { doubleSubmitToken: csrfToken, }) - expect(output.success).toBe(true) - expect(output.session).not.toBeNull() - expect(createSessionMock).toHaveBeenCalledWith({ + 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: "1234567890", + userId: "user-123", deviceId: null, - authenticatedWith: "oauth", + authenticatedWith: "credentials", status: "active", mfaState: "none", tokenHash: expect.any(String), expiresAt: expect.any(Date), metadata: null, }) - - vi.unstubAllGlobals() + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) }) test("handles invalid doubleSubmitToken parameter", async () => { @@ -998,22 +1144,29 @@ describe("refreshUserInfo API (Stateful)", () => { 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 getUserByIdMock = vi.fn().mockResolvedValue(sessionEntityWithUser.user) - const createUserMock = vi.fn() - const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) 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, - getUserById: getUserByIdMock, - createUser: createUserMock, - createSession: createSessionMock, updateUser: updateUserMock, + updateSession: updateSessionMock, + touchSession: touchSessionMock, }) const sessionToken = "valid-session-token" @@ -1025,9 +1178,9 @@ describe("refreshUserInfo API (Stateful)", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -1039,19 +1192,65 @@ describe("refreshUserInfo API (Stateful)", () => { skipCSRFCheck: true, }) - expect(output.success).toBe(true) - expect(output.session).not.toBeNull() - expect(createSessionMock).toHaveBeenCalledWith({ + 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: "1234567890", + userId: "user-123", deviceId: null, - authenticatedWith: "oauth", + authenticatedWith: "credentials", status: "active", mfaState: "none", tokenHash: expect.any(String), expiresAt: expect.any(Date), metadata: null, }) - vi.unstubAllGlobals() + expect(touchSessionMock).toHaveBeenCalledWith("session-123", expect.any(Date)) }) }) From 3f1e368485dfc81143560b9cc32d7917fae45c03 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 26 Jul 2026 11:29:20 -0500 Subject: [PATCH 4/4] test(core): fix /providers/:oauth/user/refresh tests cases --- packages/core/src/api/refreshUserInfo.ts | 1 + packages/core/src/session/stateless.ts | 2 +- packages/core/src/shared/utils.ts | 2 +- .../providers/user/refresh/stateful.test.ts | 821 +++++++++++++----- .../providers/user/refresh/stateless.test.ts | 59 +- .../test/api/stateful/refreshUserInfo.test.ts | 5 +- .../api/stateless/refreshUserInfo.test.ts | 14 +- packages/core/vitest.config.ts | 2 +- 8 files changed, 668 insertions(+), 238 deletions(-) diff --git a/packages/core/src/api/refreshUserInfo.ts b/packages/core/src/api/refreshUserInfo.ts index a8297c21..f5735552 100644 --- a/packages/core/src/api/refreshUserInfo.ts +++ b/packages/core/src/api/refreshUserInfo.ts @@ -96,6 +96,7 @@ export const refreshUserInfo = async ( }, } 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/stateless.ts b/packages/core/src/session/stateless.ts index a4722ec9..6e909a74 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -400,7 +400,7 @@ export const createStatelessStrategy = ({ identity, sessionToken, }) - const mergedHeaders = toUnionHeaders(newHeaders, headers) + const mergedHeaders = toUnionHeaders(newHeaders, secureApiHeaders) return { session, headers: mergedHeaders } } 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 index 2ecbd669..c3e2ebd4 100644 --- a/packages/core/test/actions/providers/user/refresh/stateful.test.ts +++ b/packages/core/test/actions/providers/user/refresh/stateful.test.ts @@ -1,37 +1,98 @@ import { describe, test, expect, vi } from "vitest" -import { jose, oauthCustomService, oauthTokens, POST, sessionPayload } from "@test/presets.ts" +import { + authInstance, + jose, + oauthAccountEntity, + oauthCustomService, + oauthTokens, + sessionEntityWithUser, + userEntity, +} from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" -import { createAuth } from "@/createAuth.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.", }) - }) - test("invalid operation when the session token is missing", async () => { - const response = await POST( - new Request("https://example.com/auth/providers/oauth-provider/user/refresh", { method: "POST" }) - ) - 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 missing", async () => { - const sessionToken = await jose.encodeJWT(sessionPayload) + 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=${sessionToken}`, + Cookie: `aura-auth.session_token=valid-token-hash`, }, }) ) @@ -39,17 +100,54 @@ describe("refreshUserInfo action", () => { 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 sessionToken = await jose.encodeJWT(sessionPayload) 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=${sessionToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, "X-CSRF-Token": "invalid-token", }, }) @@ -58,18 +156,55 @@ describe("refreshUserInfo action", () => { 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 sessionToken = await jose.encodeJWT(sessionPayload) 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=${sessionToken}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -77,21 +212,58 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + const csrfToken = await createCSRF(jose) const mockFetch = vi.fn() mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, + id: "1234567890", email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -101,7 +273,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -110,10 +282,12 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, + sub: "user-123", email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) expect(response.status).toBe(200) @@ -126,38 +300,83 @@ describe("refreshUserInfo action", () => { }, signal: expect.any(AbortSignal), }) - }) - - test("handles getUserInfo network error gracefully", async () => { - const csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - - const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) - - const mockFetch = vi.fn().mockRejectedValueOnce(new Error("Network connection lost")) - 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, - }, - }) - ) + 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(await response.json()).toEqual({ - success: false, - session: null, + 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + const csrfToken = await createCSRF(jose) const mockFetch = vi.fn().mockResolvedValueOnce({ ok: false, @@ -170,7 +389,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -179,13 +398,48 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + const csrfToken = await createCSRF(jose) const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true, @@ -202,7 +456,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -211,16 +465,52 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + 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", @@ -233,7 +523,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -242,26 +532,63 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT({ - ...oauthTokens, - expiresAt: Math.floor(Date.now() / 1000) - 3600, - } as unknown as Record) + 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 } = createAuth({ oauth: [spread] }) - const response = await handlers.POST( + 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -270,17 +597,55 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - const encodedTokens = await jose.encodeJWT({ - ...oauthTokens, - expiresAt: Math.floor(Date.now() / 1000) - 3600, - } as unknown as Record) + 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({ @@ -293,8 +658,9 @@ describe("refreshUserInfo action", () => { headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ id: "1234567890", - ...sessionPayload, email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", }), }) vi.stubGlobal("fetch", mockFetch) @@ -304,7 +670,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -313,83 +679,110 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, + sub: "user-123", + name: "John Doe Updated", email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) expect(mockFetch).toHaveBeenCalledTimes(2) - }) - - test("handles invalid user info response with missing content type", async () => { - const csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - - const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) - const mockFetch = vi.fn().mockResolvedValueOnce({ - ok: true, - headers: { - get: (name: string) => (name === "content-type" ? "text/html" : null), - }, - json: async () => ({ - id: "1234567890", - email: "john@example.com", - }), + 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", }) - 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=${sessionToken}; aura-auth.access_token.oauth-provider=${encodedTokens}`, - }, - }) - ) - expect(await response.json()).toEqual({ - success: false, - session: null, + 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 session token verification failure", async () => { - const csrfToken = await createCSRF(jose) - const invalidSessionToken = "invalid.session.token" - - 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=${invalidSessionToken}`, - }, - }) - ) - - expect(await response.json()).toEqual({ - success: false, - session: null, + 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, }) - }) - test("updates session cookie with new session token", async () => { const csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - - const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) - const mockFetch = vi.fn() - mockFetch.mockResolvedValueOnce({ + const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true, - headers: new Headers({ "Content-Type": "application/json" }), + headers: new Headers({ + "Content-Type": "text/plain", + }), json: async () => ({ - ...sessionPayload, id: "1234567890", - email: "john.updated@example.com", + email: "john@example.com", }), }) vi.stubGlobal("fetch", mockFetch) @@ -399,34 +792,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, - }, - }) - ) - - expect(await response.json()).toEqual({ - success: true, - session: { - user: { - ...sessionPayload, - email: "john.updated@example.com", - }, - expires: expect.any(Number), - }, - }) - expect(response.headers.get("set-cookie")).toContain("aura-auth.session_token=") - }) - - test("handles malformed provider tokens cookie", async () => { - const csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - - 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=${sessionToken}; aura-auth.access_token.oauth-provider=malformed-token`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, }, }) ) @@ -435,22 +801,58 @@ describe("refreshUserInfo action", () => { 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 csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) + 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 encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + const csrfToken = await createCSRF(jose) const mockFetch = vi.fn() mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ - ...sessionPayload, id: "1234567890", email: "john.updated@example.com", + name: "John Doe Updated", + image: "https://example.com/image-updated.jpg", nickname: "johnny", email_verified: true, }), @@ -462,7 +864,7 @@ describe("refreshUserInfo action", () => { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${sessionToken}; __Secure-aura-auth.access_token.oauth-profile=${encodedTokens}`, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, }, }) ) @@ -471,50 +873,61 @@ describe("refreshUserInfo action", () => { success: true, session: { user: { - ...sessionPayload, + sub: "user-123", + name: "John Doe Updated", email: "john.updated@example.com", + image: "https://example.com/image-updated.jpg", }, - expires: expect.any(Number), + expires: expect.any(String), }, }) - }) - - test("toResponse returns correct response on success", async () => { - const csrfToken = await createCSRF(jose) - const sessionToken = await jose.encodeJWT(sessionPayload) - - const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) - const mockFetch = vi.fn() - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers({ "Content-Type": "application/json" }), - json: async () => ({ - ...sessionPayload, - email: "john.updated@example.com", - }), + 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", }) - 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=${sessionToken}; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, - }, - }) - ) - expect(await response.json()).toEqual({ - success: true, - session: { - user: { - ...sessionPayload, - email: "john.updated@example.com", - }, - expires: expect.any(Number), - }, + 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/refresh/stateless.test.ts b/packages/core/test/actions/providers/user/refresh/stateless.test.ts index ab0373e7..89460337 100644 --- a/packages/core/test/actions/providers/user/refresh/stateless.test.ts +++ b/packages/core/test/actions/providers/user/refresh/stateless.test.ts @@ -111,10 +111,13 @@ describe("refreshUserInfo action", () => { expect(await response.json()).toEqual({ success: true, session: { - sub: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + user: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), }, }) expect(response.status).toBe(200) @@ -314,10 +317,13 @@ describe("refreshUserInfo action", () => { expect(await response.json()).toEqual({ success: true, session: { - sub: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + user: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), }, }) expect(mockFetch).toHaveBeenCalledTimes(2) @@ -409,10 +415,13 @@ describe("refreshUserInfo action", () => { expect(await response.json()).toEqual({ success: true, session: { - sub: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + user: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), }, }) expect(response.headers.get("set-cookie")).toContain("aura-auth.session_token=") @@ -471,12 +480,15 @@ describe("refreshUserInfo action", () => { expect(await response.json()).toEqual({ success: true, - session: expect.objectContaining({ - sub: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", - }), + session: { + user: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), + }, }) }) @@ -512,10 +524,13 @@ describe("refreshUserInfo action", () => { expect(await response.json()).toEqual({ success: true, session: { - sub: "1234567890", - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", + user: { + sub: "1234567890", + email: "john@example.com", + name: "John Doe", + image: "https://example.com/image.jpg", + }, + expires: expect.any(String), }, }) }) diff --git a/packages/core/test/api/stateful/refreshUserInfo.test.ts b/packages/core/test/api/stateful/refreshUserInfo.test.ts index 0cc3d9f4..23c5821e 100644 --- a/packages/core/test/api/stateful/refreshUserInfo.test.ts +++ b/packages/core/test/api/stateful/refreshUserInfo.test.ts @@ -529,6 +529,7 @@ describe("refreshUserInfo API (Stateful)", () => { const mockFetch = vi.fn().mockResolvedValueOnce({ ok: true, + headers: new Headers({ "Content-Type": "application/json" }), json: async () => ({ email: "john@example.com", name: "John Doe", @@ -547,8 +548,8 @@ describe("refreshUserInfo API (Stateful)", () => { success: false, session: null, error: { - code: "UNKNOWN_OAUTH_USER_INFO_ERROR", - message: "Failed to communicate clean state down to the user configuration data provider.", + 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), 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), diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index c47d52a6..2b40b403 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ test: { name: "core", include: ["test/**/*.test.ts"], - exclude: ["test/rate-limiter.test.ts", "test/actions/providers/user/refresh/stateless.test.ts"], + exclude: ["test/rate-limiter.test.ts"], setupFiles: ["./test/setup/vitest.setup.ts", "./test/setup/actions.setup.ts"], }, resolve: {