diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 10447cbe..d3723635 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -226,7 +226,7 @@ export interface SessionStrategy { * Read and validate the session from an incoming request. * Returns null if absent, invalid, or expired. Never throws on auth failure. */ - getSession(request: Headers): Promise> + getSession(headers: Headers): Promise> /** * Create a session after successful authentication. @@ -260,7 +260,12 @@ export interface SessionStrategy { * Destroy the session attached to this request (logout). * Returns a response that clears cookies. */ - destroySession(request: Headers, skipCSRFCheck?: boolean): Promise + destroySession(headers: Headers, skipCSRFCheck?: boolean): Promise + + /** + * Revoke the access token for a specific OAuth provider. + */ + revokeToken(oauth: string, headers: Headers, disconnect: boolean): Promise } /** Inputs for constructing a session strategy implementation for a given identity schema. */ diff --git a/packages/core/src/api/revokeToken.ts b/packages/core/src/api/revokeToken.ts index 845e92b4..93b83df9 100644 --- a/packages/core/src/api/revokeToken.ts +++ b/packages/core/src/api/revokeToken.ts @@ -1,57 +1,13 @@ -import { AuraAuthError } from "@/shared/errors.ts" -import { HeadersBuilder } from "@aura-stack/router" -import { fetchAsync } from "@/shared/fetch-async.ts" import { secureApiHeaders } from "@/shared/headers.ts" -import { getCookie, getExpiredCookie } from "@/cookie.ts" import { createValidation, handleApiError } from "@/shared/utils/api.ts" -import { createBasicAuthHeader, toUnionHeaders } from "@/shared/utils.ts" import type { FunctionAPIContext, RevokeTokenAPIOptions, RevokeTokenAPIReturn, LiteralUnion, BuiltInOAuthProvider, - RuntimeOAuthProvider, } from "@/@types/index.ts" -const revokeProviderToken = async (provider: RuntimeOAuthProvider, accessToken: string) => { - if (!provider.revokeToken || (typeof provider.revokeToken === "object" && !("url" in provider.revokeToken))) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_CONFIG" }) - } - if (!accessToken) { - throw new AuraAuthError({ code: "INVALID_ACCESS_TOKEN" }) - } - const { tokenHint: hintParam, ...extraParams } = - typeof provider.revokeToken === "object" && provider.revokeToken.params - ? provider.revokeToken.params - : ({} as Record) - const tokenHint = hintParam ?? "access_token" - - const url = typeof provider.revokeToken === "string" ? provider.revokeToken : provider.revokeToken.url - const basicAuth = createBasicAuthHeader(provider.clientId!, provider.clientSecret!) - - const response = await fetchAsync(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: basicAuth, - ...(typeof provider.revokeToken === "object" && provider.revokeToken.headers ? provider.revokeToken.headers : {}), - }, - body: new URLSearchParams({ - token: accessToken, - token_type_hint: tokenHint, - ...extraParams, - }), - }) - if (!response.ok) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_RESPONSE" }) - } - if (response.status !== 200 && response.status !== 204) { - throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_PROCESS" }) - } - return true -} - export const revokeToken = async ( oauth: LiteralUnion, { @@ -63,7 +19,6 @@ export const revokeToken = async ( disconnect = false, }: FunctionAPIContext & { disconnect?: boolean } ): Promise => { - const { cookies } = ctx try { ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { structuredData: { @@ -73,7 +28,7 @@ export const revokeToken = async ( }, }) - const { provider, headers, request, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) + const { headers, rateLimit } = await createValidation(ctx, headersInit ?? requestInit?.headers) .verifyOAuthProvider(oauth) .verifySession() .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) @@ -88,33 +43,12 @@ export const revokeToken = async ( return rateLimit as RevokeTokenAPIReturn } - const cookieName = `${cookies.accessToken.name}.${oauth}` - const cookie = getCookie(request, cookieName) - - const decodedToken = await ctx.jwtManager.verifyToken(cookie) - const tokens = await ctx.identity.schemaRegistry.parseOAuthTokens(decodedToken) - - if (!disconnect) { - ctx.logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { - structuredData: { provider: oauth, hasAccessToken: !!tokens.accessToken }, - }) - - await revokeProviderToken(provider!, tokens.accessToken) - - ctx.logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { - structuredData: { provider: oauth }, - }) - } - - const builder = new HeadersBuilder(secureApiHeaders) - .setCookie(cookieName, "", getExpiredCookie(cookies.accessToken.attributes)) - .toHeaders() - const newHeaders = toUnionHeaders(builder, headers) + const revokeHeaders = await ctx.sessionStrategy.revokeToken(oauth, headers, disconnect) return { success: true, - headers: newHeaders, + headers: revokeHeaders, toResponse: () => { - return Response.json({ success: true }, { status: 200, headers: newHeaders }) + return Response.json({ success: true }, { status: 200, headers: revokeHeaders }) }, } } catch (error) { diff --git a/packages/core/src/session/stateful.ts b/packages/core/src/session/stateful.ts index b4db56ea..90c1875c 100644 --- a/packages/core/src/session/stateful.ts +++ b/packages/core/src/session/stateful.ts @@ -1,8 +1,9 @@ import { AuraAuthError } from "@/shared/errors.ts" import { secureApiHeaders } from "@/shared/headers.ts" -import { verifyCSRFToken, getErrorName, shouldRefresh } from "@/shared/utils.ts" +import { verifyCSRFToken, getErrorName, shouldRefresh, toUnionHeaders } from "@/shared/utils.ts" import { handleApiError } from "@/shared/utils/api.ts" import { refreshProviderToken } from "@/shared/utils/refresh-tokens.ts" +import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" import { createCookieManager } from "@/session/cookie-manager.ts" import { createHash, createSecretValue } from "@/shared/crypto.ts" import type { JoseInstance } from "@/@types/index.ts" @@ -16,6 +17,8 @@ import type { SessionStrategy, User, } from "@/@types/session.ts" +import { HeadersBuilder } from "@aura-stack/router" +import { getExpiredCookie } from "@/cookie.ts" export const createStatefulStrategy = ({ config, @@ -845,11 +848,122 @@ export const createStatefulStrategy = ({ } } + const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { + provider: oauthId, + operation: "revokeToken", + disconnect, + }, + }) + + try { + const { sessionToken } = cookieConfig.getCookie(headers) + if (!sessionToken) { + logger?.log("SESSION_TOKEN_MISSING", { + structuredData: { + reason: "no_session_token", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + const sessionByToken = await config.adapter.getSessionByToken(sessionToken) + if (!sessionByToken || !sessionByToken.user) { + logger?.log("AUTH_SESSION_INVALID", { + structuredData: { + reason: "session_not_found_or_no_user", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + 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", + }, + }) + throw new AuraAuthError({ code: "SESSION_NOT_FOUND" }) + } + + logger?.log("AUTH_SESSION_VALID", { + structuredData: { + user_id: sessionByToken.userId, + session_id: sessionByToken.id, + }, + }) + + const oauthAccount = await config.adapter.getOAuthAccount(oauthId) + if (!oauthAccount) { + logger?.log("OAUTH_UNLINKED_ACCOUNT_ERROR", { + structuredData: { + provider: oauthId, + reason: "oauth_account_not_found", + }, + }) + throw new AuraAuthError({ code: "OAUTH_UNLINKED_ACCOUNT_ERROR" }) + } + + const provider = oauth?.[oauthId] + if (!provider) { + logger?.log("INVALID_OAUTH_CONFIGURATION", { + structuredData: { + provider: oauthId, + reason: "provider_not_configured", + }, + }) + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + if (!disconnect) { + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasAccessToken: !!oauthAccount.accessToken }, + }) + + await revokeProviderToken(provider, oauthAccount.accessToken) + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { provider: oauthId }, + }) + } + + await config.adapter.updateAccountStatus(oauthAccount.accountId, "unlinked") + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { + provider: oauthId, + account_unlinked: true, + }, + }) + + const cookieName = `${cookies().accessToken.name}.${oauthId}` + const builder = new HeadersBuilder(secureApiHeaders) + .setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes)) + .toHeaders() + return toUnionHeaders(builder, headers) + } catch (error) { + logger?.log("OAUTH_ACCESS_TOKEN_ERROR", { + structuredData: { + provider: oauthId, + error_type: getErrorName(error), + error_message: error instanceof Error ? error.message : String(error), + }, + }) + throw error + } + } + return { getSession, createSession, refreshSession, revokeSession, + revokeToken, destroySession, getProviderTokens, } diff --git a/packages/core/src/session/stateless.ts b/packages/core/src/session/stateless.ts index f8c5994c..faceffb0 100644 --- a/packages/core/src/session/stateless.ts +++ b/packages/core/src/session/stateless.ts @@ -1,4 +1,4 @@ -import { getCookie } from "@/cookie.ts" +import { getCookie, getExpiredCookie } from "@/cookie.ts" import { AuraAuthError } from "@/shared/errors.ts" import { HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/shared/headers.ts" @@ -18,6 +18,7 @@ import type { JoseInstance, GetProviderTokensStatefulReturn, } from "@/@types/index.ts" +import { revokeProviderToken } from "@/shared/utils/revoke-token.ts" export const createStatelessStrategy = ({ config, @@ -330,6 +331,35 @@ export const createStatelessStrategy = ({ } } + const revokeToken = async (oauthId: string, headers: Headers, disconnect: boolean): Promise => { + const cookieName = `${cookies().accessToken.name}.${oauthId}` + const cookie = getCookie(headers, cookieName) + const provider = oauth[oauthId] + + if (!provider) { + throw new AuraAuthError({ code: "UNSUPPORTED_OAUTH_CONFIGURATION" }) + } + + if (!disconnect) { + const decodedToken = await jwt.verifyToken(cookie) + const tokens = await identity.schemaRegistry.parseOAuthTokens(decodedToken) + + logger?.log("OAUTH_ACCESS_TOKEN_REQUEST_INITIATED", { + structuredData: { provider: oauthId, hasAccessToken: !!tokens.accessToken }, + }) + + await revokeProviderToken(provider, tokens.accessToken) + + logger?.log("OAUTH_ACCESS_TOKEN_SUCCESS", { + structuredData: { provider: oauthId }, + }) + } + const builder = new HeadersBuilder(secureApiHeaders) + .setCookie(cookieName, "", getExpiredCookie(cookies().accessToken.attributes)) + .toHeaders() + return toUnionHeaders(builder, headers) + } + // JWT strategy: stateless tokens cannot be revoked server-side const revokeSession = async (_sessionId: string): Promise => {} @@ -339,5 +369,5 @@ export const createStatelessStrategy = ({ return cookieConfig.clear() } - return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, destroySession } + return { getSession, createSession, getProviderTokens, refreshSession, revokeSession, revokeToken, destroySession } } diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 0ce3b285..ed6a2925 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -129,6 +129,8 @@ export const AuraErrorCode = { * Database Errors */ DATABASE_TOKEN_HASH_NOT_FOUND: "DATABASE_TOKEN_HASH_NOT_FOUND", + + OAUTH_UNLINKED_ACCOUNT_ERROR: "OAUTH_UNLINKED_ACCOUNT_ERROR", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -899,6 +901,14 @@ export const ERROR_CATALOG: Record = { "The session verification query completed successfully, but the database returned a nullish record matching the provided session token hash identifier reference.", userMessage: "The session was not found in the database. Please sign in again.", }, + OAUTH_UNLINKED_ACCOUNT_ERROR: { + type: "AUTH_FLOW", + statusCode: 400, + name: "OAuthError", + message: + "No linked connection exists between the selected OAuth provider and the active user session. The revocation or unlinking operation was aborted.", + userMessage: "The specified identity provider is not connected to your account.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index e2d05735..19be4549 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -813,6 +813,12 @@ export const logMessages = { msgId: "STATELESS_GET_PROVIDER_TOKENS_ERROR", message: "Error occurred during stateless getProviderTokens", }, + OAUTH_UNLINKED_ACCOUNT_ERROR: { + facility: 4, + severity: "error", + msgId: "OAUTH_UNLINKED_ACCOUNT_ERROR", + message: "Error occurred while unlinking OAuth account from user", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 8d12ea3d..c75316dd 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -213,7 +213,7 @@ export const shouldRefresh = (payload: OAuthTokenPayload, refreshWindow: number) export const merge = (origin: Record, source: Record) => { for (const key in source) { - if (source[key] instanceof Object && key in origin) { + if (source[key] instanceof Object && !(source[key] instanceof Array) && key in origin) { Object.assign(source[key], merge(origin[key] as Record, source[key] as Record)) } } diff --git a/packages/core/src/shared/utils/revoke-token.ts b/packages/core/src/shared/utils/revoke-token.ts new file mode 100644 index 00000000..7f1c57e7 --- /dev/null +++ b/packages/core/src/shared/utils/revoke-token.ts @@ -0,0 +1,42 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { fetchAsync } from "@/shared/fetch-async.ts" +import { createBasicAuthHeader } from "@/shared/utils.ts" +import type { RuntimeOAuthProvider } from "@/@types/oauth.ts" + +export const revokeProviderToken = async (provider: RuntimeOAuthProvider, accessToken: string) => { + if (!provider.revokeToken || (typeof provider.revokeToken === "object" && !("url" in provider.revokeToken))) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_CONFIG" }) + } + if (!accessToken) { + throw new AuraAuthError({ code: "INVALID_ACCESS_TOKEN" }) + } + const { tokenHint: hintParam, ...extraParams } = + typeof provider.revokeToken === "object" && provider.revokeToken.params + ? provider.revokeToken.params + : ({} as Record) + const tokenHint = hintParam ?? "access_token" + + const url = typeof provider.revokeToken === "string" ? provider.revokeToken : provider.revokeToken.url + const basicAuth = createBasicAuthHeader(provider.clientId!, provider.clientSecret!) + + const response = await fetchAsync(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: basicAuth, + ...(typeof provider.revokeToken === "object" && provider.revokeToken.headers ? provider.revokeToken.headers : {}), + }, + body: new URLSearchParams({ + token: accessToken, + token_type_hint: tokenHint, + ...extraParams, + }), + }) + if (!response.ok) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_RESPONSE" }) + } + if (response.status !== 200 && response.status !== 204) { + throw new AuraAuthError({ code: "OAUTH_INVALID_REVOKE_TOKEN_PROCESS" }) + } + return true +} diff --git a/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts new file mode 100644 index 00000000..baf3f3a2 --- /dev/null +++ b/packages/core/test/actions/providers/tokens/revoke/stateful.test.ts @@ -0,0 +1,595 @@ +import { describe, test, expect, vi } from "vitest" +import { createCSRF } from "@/shared/crypto.ts" +import { + accountStatusEntity, + authInstance, + jose, + oauthAccountEntity, + oauthCustomService, + oauthTokens, + sessionEntityWithUser, +} from "@test/presets.ts" + +describe("Revoke Action", () => { + test("throws error when provider is not configured", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const response = await POST( + new Request("https://example.com/auth/providers/unsupported/tokens/revoke", { + method: "POST", + 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(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when session token is missing", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: new Headers(), + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is missing", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + Cookie: `__Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is invalid", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + "X-CSRF-Token": "invalid-token", + }, + }) + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledOnce() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when provider token cookie does not exist", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when provider does not have revoke token configured", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { revokeToken: _, ...spread } = oauthCustomService + + const { + handlers: { POST }, + } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }, + { oauth: [spread] } + ) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("successfully revokes token", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + }) + + const setCookieHeader = response.headers.get("set-cookie") + expect(setCookieHeader).toContain("aura-auth.access_token.oauth-provider=") + expect(setCookieHeader).toContain("Expires=") + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + }) + + 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 { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + 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 }) + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + }) + + 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 { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + 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/tokens/revoke", { + method: "POST", + 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(await response.json()).toEqual({ success: false }) + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + 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 { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + 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 { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 201, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + 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 { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("handles expired session token", async () => { + const getSessionByTokenMock = vi.fn().mockResolvedValue({ + ...sessionEntityWithUser, + expiresAt: new Date(Date.now() - 3600 * 1000), + }) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue(accountStatusEntity) + + const { + handlers: { POST }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ success: false }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + 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 customRevokeService = { + ...oauthCustomService, + revokeToken: "https://custom.example.com/revoke", + } + + const { + handlers: { POST }, + } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }, + { oauth: [customRevokeService] } + ) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ success: true }) + expect(mockFetch).toHaveBeenCalledWith("https://custom.example.com/revoke", expect.any(Object)) + + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + }) + + 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 customRevokeService = { + ...oauthCustomService, + revokeToken: { + url: "https://custom.example.com/revoke", + params: { + tokenHint: "refresh_token", + }, + headers: { + "X-Custom-Header": "custom-value", + }, + }, + } + + const { + handlers: { POST }, + } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }, + { oauth: [customRevokeService] } + ) + + const csrfToken = await createCSRF(jose) + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "Content-Type": "application/json" }), + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const response = await POST( + new Request("https://example.com/auth/providers/oauth-provider/tokens/revoke", { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Host-aura-auth.csrf_token=${csrfToken}; __Secure-aura-auth.session_token=valid-token-hash`, + }, + }) + ) + + expect(await response.json()).toEqual({ + success: true, + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith("valid-token-hash") + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + + expect(mockFetch).toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.example.com/revoke", + expect.objectContaining({ + method: "POST", + body: expect.any(URLSearchParams), + headers: { + Authorization: expect.stringContaining("Basic"), + "Content-Type": "application/x-www-form-urlencoded", + "X-Custom-Header": "custom-value", + }, + signal: expect.any(AbortSignal), + }) + ) + }) +}) diff --git a/packages/core/test/api/stateful/revokeToken.test.ts b/packages/core/test/api/stateful/revokeToken.test.ts new file mode 100644 index 00000000..60560dbf --- /dev/null +++ b/packages/core/test/api/stateful/revokeToken.test.ts @@ -0,0 +1,969 @@ +import { describe, test, expect, vi } from "vitest" +import { createCSRF } from "@/shared/crypto.ts" +import { authInstance, jose, oauthAccountEntity, oauthCustomService, sessionEntityWithUser } from "@test/presets.ts" + +describe("revokeToken (Stateful)", () => { + test("throws error when provider is not configured", async () => { + const { api } = authInstance({}) + + const output = await api.revokeToken("unsupported", { headers: new Headers() }) + expect(output).toEqual({ + success: 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("throws error when session token is missing", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const output = await api.revokeToken("oauth-provider", { headers: new Headers() }) + expect(output).toEqual({ + success: false, + error: { + code: "COOKIE_NOT_FOUND", + message: "No cookies found. There is no active session.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).not.toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is missing", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.session_token=valid-token-hash`, + }, + }) + expect(output).toEqual({ + success: false, + 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).toHaveBeenCalled() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when CSRF token is invalid", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=valid-token-hash`, + "X-CSRF-Token": "invalid-token", + }, + }) + expect(output).toEqual({ + success: false, + error: { + code: "OAUTH_UNLINKED_ACCOUNT_ERROR", + message: "The specified identity provider is not connected to your account.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledTimes(2) + expect(getOAuthAccountMock).toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when session not found in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(null) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + 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(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("throws error when OAuth account does not exist in database", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(null) + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + expect(output).toEqual({ + success: false, + error: { + code: "OAUTH_UNLINKED_ACCOUNT_ERROR", + message: "The specified identity provider is not connected to your account.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("successfully revokes token with OAuth provider call and database unlink", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValue(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn().mockResolvedValue(oauthAccountEntity) + const updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + }) + + test("successfully revokes token with disconnect=true (only database unlink, no OAuth call)", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + 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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + 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 () => ({ success: true }), + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.disconnectProvider("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + expect(mockFetch).not.toHaveBeenCalled() + }) + + test("handles network error during OAuth provider revocation", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + 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 updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + 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.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + error: { + code: "UNKNOWN_REVOKE_TOKEN_ERROR", + message: "Failed to revoke token for the OAuth provider", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("handles provider returning error response", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + 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 updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 400, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + error: { + code: "OAUTH_INVALID_REVOKE_TOKEN_RESPONSE", + message: "Failed to communicate token revocation down to the identity provider.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("handles provider returning unexpected status code", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + 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 updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 201, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + error: { + code: "OAUTH_INVALID_REVOKE_TOKEN_PROCESS", + message: "The identity provider encountered an error while processing the token revocation.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + 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({ + 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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + const response = output.toResponse() + expect(response.status).toBe(200) + + const responseBody = await response.json() + expect(responseBody).toEqual({ + success: true, + }) + }) + + test("toResponse returns correct response on failure", async () => { + const { api } = authInstance({}) + const output = await api.revokeToken("unsupported") + + const response = output.toResponse() + expect(response.status).toBe(400) + + const responseBody = await response.json() + expect(responseBody).toEqual({ + success: false, + }) + }) + + test("handles expired session", 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 getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + const revokeSessionMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + revokeSession: revokeSessionMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + 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(revokeSessionMock).toHaveBeenCalledWith(expiredSession.id, "user_logout") + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("handles session with inactive status", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const inactiveSession = { + ...sessionEntityWithUser, + status: "revoked" as const, + } + const getSessionByTokenMock = vi.fn() + getSessionByTokenMock.mockResolvedValueOnce(inactiveSession) + getSessionByTokenMock.mockResolvedValueOnce(inactiveSession) + + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output).toEqual({ + success: false, + 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(updateAccountStatusMock).not.toHaveBeenCalled() + }) + + test("handles provider with custom revoke token URL", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const customRevokeService = { + ...oauthCustomService, + revokeToken: "https://custom.example.com/revoke", + } + const { api } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }, + { oauth: [customRevokeService] } + ) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output.success).toBe(true) + expect(mockFetch).toHaveBeenCalledWith("https://custom.example.com/revoke", expect.any(Object)) + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + }) + + test("handles provider with custom revoke token config object", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const customRevokeService = { + ...oauthCustomService, + revokeToken: { + url: "https://custom.example.com/revoke", + params: { + tokenHint: "refresh_token", + }, + headers: { + "X-Custom-Header": "custom-value", + }, + }, + } + + const { api } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }, + { oauth: [customRevokeService] } + ) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + }) + + expect(output.success).toBe(true) + expect(mockFetch).toHaveBeenCalledWith( + "https://custom.example.com/revoke", + expect.objectContaining({ + headers: expect.objectContaining({ + "Content-Type": "application/x-www-form-urlencoded", + "X-Custom-Header": "custom-value", + }), + }) + ) + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + }) + + test("revokeToken with doubleSubmitToken", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + doubleSubmitToken: csrfToken, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + }) + + test("revokeToken with doubleSubmitToken and invalid value", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + doubleSubmitToken: "invalid-token", + }) + + expect(output).toEqual({ + success: false, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledOnce() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test("revokeToken enables double-submit cookie manually", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn() + + getSessionByTokenMock.mockResolvedValueOnce(sessionEntityWithUser) + getSessionByTokenMock.mockResolvedValueOnce(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 updateAccountStatusMock = vi.fn().mockResolvedValue({ + accountId: "account-123", + status: "unlinked", + }) + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }) + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: true, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionToken) + expect(getOAuthAccountMock).toHaveBeenCalledWith("oauth-provider") + expect(updateAccountStatusMock).toHaveBeenCalledWith("account-123", "unlinked") + expect(mockFetch).toHaveBeenCalledWith("https://example.com/oauth/revoke_token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: expect.stringContaining("Basic"), + }, + body: expect.any(URLSearchParams), + signal: expect.any(AbortSignal), + }) + }) + + test("revokeToken enables double-submit cookie manually and invalid token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockResolvedValueOnce(sessionEntityWithUser) + const getOAuthAccountMock = vi.fn() + const updateAccountStatusMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getOAuthAccount: getOAuthAccountMock, + updateAccountStatus: updateAccountStatusMock, + }) + + const csrfToken = await createCSRF(jose) + const sessionToken = "valid-session-token" + + const mockFetch = vi.fn() + vi.stubGlobal("fetch", mockFetch) + + const output = await api.revokeToken("oauth-provider", { + headers: { + "X-CSRF-Token": "invalid-token", + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.session_token=${sessionToken}`, + }, + skipCSRFCheck: false, + }) + + expect(output).toEqual({ + success: false, + error: { + code: "CSRF_TOKEN_MISMATCH", + message: "CSRF token verification failed. Please refresh and try again.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledOnce() + expect(getOAuthAccountMock).not.toHaveBeenCalled() + expect(updateAccountStatusMock).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index 830dc4c3..4a11f0e8 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -1,8 +1,10 @@ import { createAuth } from "@/createAuth.ts" import type { JWTPayload } from "@/jose.ts" import type { + AccountEntity, AuthConfig, DatabaseAdapter, + OAuthAccountEntity, OAuthProviderCredentials, OAuthTokenPayload, OpenIDProvider, @@ -114,6 +116,24 @@ export const sessionEntityWithUser: SessionWithUserEntity = { user: userEntity, } +export const oauthAccountEntity: OAuthAccountEntity = { + 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(), +} + +export const accountStatusEntity: Partial = { + id: "account-123", + status: "unlinked", +} + const auth = createAuth({ oauth: [oauthCustomService, oauthCustomServiceProfile, openIDCustomProvider], logger: getEnv("CI") === "true" ? false : true,