diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index d3723635..163be2df 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -264,8 +264,15 @@ export interface SessionStrategy { /** * Revoke the access token for a specific OAuth provider. + * @unstable This API is experimental and may change in future releases. */ revokeToken(oauth: string, headers: Headers, disconnect: boolean): Promise + + /** + * Check if the user is connected to a specific OAuth provider. + * @unstable This API is experimental and may change in future releases. + */ + isProviderConnected(oauth: string, headers: Headers): Promise } /** Inputs for constructing a session strategy implementation for a given identity schema. */ diff --git a/packages/core/src/api/isProviderConnected.ts b/packages/core/src/api/isProviderConnected.ts index c6d55e5c..e696b98b 100644 --- a/packages/core/src/api/isProviderConnected.ts +++ b/packages/core/src/api/isProviderConnected.ts @@ -1,4 +1,3 @@ -import { getCookie } from "@/cookie.ts" import { secureApiHeaders } from "@/shared/headers.ts" import { createValidation, handleApiError } from "@/shared/utils/api.ts" import type { LiteralUnion } from "@/@types/utility.ts" @@ -9,36 +8,18 @@ export const isProviderConnected = async ( oauth: LiteralUnion, { ctx, headers: headersInit, request: requestInit }: FunctionAPIContext ): Promise => { - const { cookies, jwtManager } = ctx try { ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { structuredData: { provider: oauth, operation: "check_connection" }, }) - const { headers, request } = await createValidation(ctx, headersInit ?? requestInit?.headers) + const { headers } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() .buildRequest(requestInit, `/providers/${oauth}`) .execute() - const cookieName = `${cookies.accessToken.name}.${oauth}` - let cookieValue: string - try { - cookieValue = getCookie(request, cookieName) - } catch { - ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauth, hasCookie: false }, - }) - return { - success: true, - connected: false, - headers, - toResponse: () => Response.json({ success: true, connected: false }, { status: 200, headers }), - } - } - - const decodedToken = await jwtManager.verifyToken(cookieValue) - const connected = !!decodedToken + const connected = await ctx.sessionStrategy.isProviderConnected(oauth, headers) ctx.logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { structuredData: { provider: oauth, connected }, diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts index 90c1875c..0e239710 100644 --- a/packages/core/src/session/stateful.ts +++ b/packages/core/src/session/stateful.ts @@ -958,6 +958,89 @@ export const createStatefulStrategy = ({ } } + const isProviderConnected = async (oauthId: string, headers: Headers): Promise => { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + operation: "isProviderConnected", + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) { + logger?.log("SESSION_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token", + }, + }) + return false + } + + const sessionByToken = await config.adapter.getSessionByToken(sessionToken) + if (!sessionByToken || !sessionByToken.user) { + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + return false + } + + const isExpired = Date.now() > sessionByToken.expiresAt.getTime() + if (sessionByToken.status !== "active" || isExpired) { + if (isExpired) { + await config.adapter.revokeSession(sessionByToken.id, "user_logout") + } + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_expired_or_inactive", + }, + }) + return false + } + + logger?.log("AUTH_SESSION_VALID", { + structuredData: { + user_id: sessionByToken.userId, + session_id: sessionByToken.id, + }, + }) + + const accounts = await config.adapter.getAccountsByUserId(sessionByToken.userId) + const account = accounts.find((acc) => acc.provider === oauthId) + + if (!account) { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + reason: "account_not_found_for_user", + }, + }) + return false + } + + const isConnected = account.status === "active" + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { + provider: oauthId, + connected: isConnected, + }, + }) + + return isConnected + } catch (error) { + logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { + structuredData: { + provider: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + return false + } + } + return { getSession, createSession, @@ -966,5 +1049,6 @@ export const createStatefulStrategy = ({ revokeToken, destroySession, getProviderTokens, + isProviderConnected, } } diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index faceffb0..eb8382ef 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -360,6 +360,27 @@ export const createStatelessStrategy = ({ return toUnionHeaders(builder, headers) } + const isProviderConnected = async (oauthId: string, headers: Headers): Promise => { + const cookieName = `${cookies().accessToken.name}.${oauthId}` + let cookieValue: string + try { + cookieValue = getCookie(headers, cookieName) + } catch { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasCookie: false }, + }) + return false + } + + try { + const decodedToken = await jwt.verifyToken(cookieValue) + return !!decodedToken + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return false + } + } + // JWT strategy: stateless tokens cannot be revoked server-side const revokeSession = async (_sessionId: string): Promise => {} @@ -369,5 +390,14 @@ export const createStatelessStrategy = ({ return cookieConfig.clear() } - return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, revokeToken, destroySession } + return { + getSession, + createSession, + getProviderTokens, + refreshSession, + revokeSession, + revokeToken, + isProviderConnected, + destroySession, + } } diff --git a/packages/core/test/actions/providers/connected/stateful.test.ts b/packages/core/test/actions/providers/connected/stateful.test.ts new file mode 100644 index 00000000..6fa90b87 --- /dev/null +++ b/packages/core/test/actions/providers/connected/stateful.test.ts @@ -0,0 +1,307 @@ +import { describe, test, expect, vi } from "vitest" +import { createCSRF } from "@/shared/crypto.ts" +import { accountEntity, authInstance, jose, oauthTokens, sessionEntityWithUser, sessionPayload } from "@test/presets.ts" + +describe("connectedAction", () => { + test("throws error when provider is not configured", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const response = await GET(new Request("https://example.com/auth/providers/unsupported", { headers: new Headers() })) + expect(await response.json()).toEqual({ + code: "UNPROCESSABLE_ENTITY", + type: "VALIDATION", + message: "The request body or parameter schema layout contains input format errors.", + details: { + oauth: { + code: "invalid_value", + message: "The OAuth provider is not supported or invalid.", + }, + }, + }) + + expect(response.status).toBe(422) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("throws error when session token is missing", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const response = await GET(new Request("https://example.com/auth/providers/oauth-provider", { headers: new Headers() })) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + success: false, + connected: false, + }) + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("returns connected: false when provider token cookie does not exist", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(null) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("returns connected: true when provider token cookie exists and is valid", async () => { + const getSessionByTokenMock = vi + .fn() + .mockResolvedValueOnce(sessionEntityWithUser) + .mockResolvedValueOnce(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValueOnce([{ ...accountEntity, status: "active" }]) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: true, + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getAccountsByUserIdMock).toHaveBeenCalledWith(sessionEntityWithUser.userId) + }) + + test("returns connected: false when provider token cookie is malformed", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash; __Secure-aura-auth.access_token.oauth-provider=invalid-token`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + }) + + test("returns connected: false when provider token cookie is expired", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const expiredTokens = { + ...oauthTokens, + exp: Math.floor(Date.now() / 1000) - 3600, + } + const encodedExpiredTokens = await jose.encodeJWT(expiredTokens as unknown as Record) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash; __Secure-aura-auth.access_token.oauth-provider=${encodedExpiredTokens}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + }) + + test("handles expired session token", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValueOnce([ + { + id: "account-123", + userId: sessionEntityWithUser.userId, + provider: "oauth-provider", + providerUserId: "provider-user-123", + type: "oauth" as const, + status: "unlinked" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const expiredSessionPayload = { + ...sessionPayload, + exp: Math.floor(Date.now() / 1000) - 3600, + } + const expiredSessionToken = await jose.encodeJWT(expiredSessionPayload) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=${expiredSessionToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + }) + + test("handles empty cookie value", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash; __Secure-aura-auth.access_token.oauth-provider=`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + }) + + test("handles multiple providers - checks correct provider", async () => { + const getSessionByTokenMock = vi.fn() + const getAccountsByUserIdMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + + const encodedTokens = await jose.encodeJWT(oauthTokens as unknown as Record) + + const response = await GET( + new Request("https://example.com/auth/providers/oauth-profile", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash; __Secure-aura-auth.access_token.oauth-provider=${encodedTokens}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + connected: false, + }) + }) +}) diff --git a/packages/core/test/actions/providers/connected/stateless.test.ts b/packages/core/test/actions/providers/connected/stateless.test.ts index 67764118..df56d60f 100644 --- a/packages/core/test/actions/providers/connected/stateless.test.ts +++ b/packages/core/test/actions/providers/connected/stateless.test.ts @@ -84,9 +84,9 @@ describe("connectedAction", () => { }) ) - expect(response.status).toBe(400) + expect(response.status).toBe(200) expect(await response.json()).toEqual({ - success: false, + success: true, connected: false, }) }) @@ -111,9 +111,9 @@ describe("connectedAction", () => { }) ) - expect(response.status).toBe(400) + expect(response.status).toBe(200) expect(await response.json()).toEqual({ - success: false, + success: true, connected: false, }) }) diff --git a/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts index baf3f3a2..bcc95029 100644 --- a/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts +++ b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, vi } from "vitest" import { createCSRF } from "@/shared/crypto.ts" import { - accountStatusEntity, + accountEntity, authInstance, jose, oauthAccountEntity, @@ -207,7 +207,7 @@ describe("Revoke Action", () => { test("successfully revokes token", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, @@ -261,7 +261,7 @@ describe("Revoke Action", () => { test("successfully revokes token with 204 status", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, @@ -300,7 +300,7 @@ describe("Revoke Action", () => { test("handles network error during revocation", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, @@ -336,7 +336,7 @@ describe("Revoke Action", () => { test("handles provider returning error response", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, @@ -374,7 +374,7 @@ describe("Revoke Action", () => { test("handles provider returning unexpected status code", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, } = authInstance({ @@ -410,7 +410,7 @@ describe("Revoke Action", () => { test("handles malformed provider token cookie", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, } = authInstance({ @@ -444,7 +444,7 @@ describe("Revoke Action", () => { expiresAt: new Date(Date.now() - 3600 * 1000), }) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const { handlers: { POST }, @@ -476,7 +476,7 @@ describe("Revoke Action", () => { test("handles provider with custom revoke token URL", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const customRevokeService = { ...oauthCustomService, @@ -524,7 +524,7 @@ describe("Revoke Action", () => { test("handles provider with custom revoke token config object", async () => { const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) - const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountEntity) const customRevokeService = { ...oauthCustomService, diff --git a/packages/core/test/api/stateful/isProviderConnected.test.ts b/packages/core/test/api/stateful/isProviderConnected.test.ts new file mode 100644 index 00000000..b607ee22 --- /dev/null +++ b/packages/core/test/api/stateful/isProviderConnected.test.ts @@ -0,0 +1,360 @@ +import { describe, test, expect, vi } from "vitest" +import { authInstance, jose, sessionEntityWithUser } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" + +describe("isProviderConnected (Stateful)", () => { + test("throws error when provider is not configured", async () => { + const { api } = authInstance({}) + + const output = await api.isProviderConnected("unsupported", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + connected: false, + 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), + }) + }) + + test("returns connected: false when session not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser).mockResolvedValueOnce(null) + const getAccountsByUserIdMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("returns connected: false when session is expired", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const expiredSession = { + ...sessionEntityWithUser, + expiresAt: new Date(Date.now() - 3600 * 1000), + status: "active" as const, + } + const getSessionByTokenMock = vi.fn().mockResolvedValue(expiredSession) + const getAccountsByUserIdMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("returns connected: false when session is inactive", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const inactiveSession = { + ...sessionEntityWithUser, + status: "revoked" as const, + } + const getSessionByTokenMock = vi.fn().mockResolvedValue(inactiveSession) + const getAccountsByUserIdMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + }) + + test("returns connected: false when OAuth account does not exist for user", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith(sessionEntityWithUser.userId) + }) + + test("returns connected: false when account does not exist for provider", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([ + { + id: "account-456", + userId: sessionEntityWithUser.userId, + provider: "other-provider", + providerUserId: "other-provider-user", + type: "oauth" as const, + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith(sessionEntityWithUser.userId) + }) + + test("returns connected: true when account status is active", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([ + { + id: "account-123", + userId: sessionEntityWithUser.userId, + provider: "oauth-provider", + providerUserId: "provider-user-123", + type: "oauth" as const, + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith(sessionEntityWithUser.userId) + }) + + test("returns connected: false when account status is not active", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([ + { + id: "account-123", + userId: sessionEntityWithUser.userId, + provider: "oauth-provider", + providerUserId: "provider-user-123", + type: "oauth" as const, + status: "unlinked" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: true, + connected: false, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith(sessionEntityWithUser.userId) + }) + + test("toResponse returns correct response when connected", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([ + { + id: "account-123", + userId: sessionEntityWithUser.userId, + provider: "oauth-provider", + providerUserId: "provider-user-123", + type: "oauth" as const, + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("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, + connected: true, + }) + }) + + test("toResponse returns correct response when not connected", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockResolvedValue([]) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.isProviderConnected("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, + connected: false, + }) + }) + + test("toResponse returns correct response on error", async () => { + const { api } = authInstance({}) + const output = await api.isProviderConnected("unsupported") + + const response = output.toResponse() + expect(response.status).toBe(400) + + const json = await response.json() + expect(json).toEqual({ + success: false, + connected: false, + }) + }) +}) diff --git a/packages/core/test/api/stateless/isProviderConnected.test.ts b/packages/core/test/api/stateless/isProviderConnected.test.ts index c221fd53..4a411d23 100644 --- a/packages/core/test/api/stateless/isProviderConnected.test.ts +++ b/packages/core/test/api/stateless/isProviderConnected.test.ts @@ -85,12 +85,8 @@ describe("isProviderConnected", () => { }) expect(output).toEqual({ - success: false, + success: true, connected: false, - error: { - code: "OAUTH_PROVIDER_CONNECTED_ERROR", - message: "", - }, headers: expect.any(Headers), toResponse: expect.any(Function), }) @@ -115,12 +111,8 @@ describe("isProviderConnected", () => { }) expect(output).toEqual({ - success: false, + success: true, connected: false, - error: { - code: "OAUTH_PROVIDER_CONNECTED_ERROR", - message: "", - }, headers: expect.any(Headers), toResponse: expect.any(Function), }) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 4a11f0e8..a7563619 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -129,9 +129,10 @@ export const oauthAccountEntity: OAuthAccountEntity = { updatedAt: new Date(), } -export const accountStatusEntity: Partial = { +export const accountEntity: Partial = { id: "account-123", status: "unlinked", + provider: "oauth-provider", } const auth = createAuth({