diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index e05d0213..8954773f 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added the `cancelEmailChange()` server API function and the `GET /email/change/cancel` endpoint to allow users to cancel pending email address changes. [#296](https://github.com/aura-stack-ts/auth/pull/296) + - Added the `changeEmail()` and `verifyEmailChange()` server API functions to support email address changes and email verification. [#295](https://github.com/aura-stack-ts/auth/pull/295) - Added the `changePassword()` server API function and the `POST /password/change` endpoint for client-side password changes. The endpoint implements CSRF token validation to protect against cross-site request forgery (CSRF) attacks. [#294](https://github.com/aura-stack-ts/auth/pull/294) diff --git a/packages/core/src/@types/adapter.ts b/packages/core/src/@types/adapter.ts index 298bfe58..457bf95f 100644 --- a/packages/core/src/@types/adapter.ts +++ b/packages/core/src/@types/adapter.ts @@ -149,6 +149,7 @@ export interface MFAChallengesAdapter { export interface VerificationTokensAdapter { createToken(input: Omit): Promise getTokenByHash(tokenHash: string): Promise + getTokensByUserId(userId: string): Promise revokeToken(id: string): Promise } diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index 47ac25af..00807f06 100644 --- a/packages/core/src/@types/api.ts +++ b/packages/core/src/@types/api.ts @@ -473,3 +473,15 @@ export type VerifyEmailChangeData = | { success: false; redirect: false; redirectURL: null } export type VerifyEmailChangeAPIReturn = AuthActionAPIReturn + +export type CancelEmailChangeAPIOptions = APIOptionsWithRequest & + APIOptionsWithRedirectTo & + APIOptionsWithSkipCSRFCheck & { payload?: { token: string } } + +export type CancelEmailChangeData = + | { success: true; redirect: true; redirectURL: null } + | { success: true; redirect: false; redirectURL: string } + | { success: true; redirect: false; redirectURL: null } + | { success: false; redirect: false; redirectURL: null } + +export type CancelEmailChangeAPIReturn = AuthActionAPIReturn diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 7289c5d2..64271d41 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -590,20 +590,58 @@ export interface EmailChangeConfig { * token, and verification URL. * You must implement this function to send the email using your preferred email service. * @example - * sendEmailChangeVerification: async ({ email, token, url }) => { + * sendEmailChangeVerification: async ({ email, confirmToken, confirmURL }) => { * // Implement your email sending logic here * sendEmail({ * to: email, * subject: "Email Change Verification", - * html: `

Click here to verify your new email address.

`, + * html: `

Click here to verify your new email address.

`, * }) * } */ sendEmailChangeVerification?: (options: { - email?: string + /** + * The current email address of the user requesting the change. + */ + email: string + /** + * The new email address that the user wants to change to. + */ newEmail: string - token: string | null - url: string | null + /** + * The token generated for verifying the email change request. It can be used + * by `verifyEmailChange` API to confirm the email change. + * @example + * confirmToken: "abc123def456ghi789" + * + * verifyEmailChange({ token: "abc123def456ghi789" }) + */ + confirmToken: string + /** + * The URL that the user should visit to confirm the email change. + * @example + * confirmToken: "abc123def456ghi789" + * + * confirmURL: "https://example.com/auth/email/change/verify?token=abc123def456ghi789" + */ + confirmURL: string + /** + * The token generated for canceling the email change request. It can be used by + * `cancelEmailChange` API to cancel the email change request. + * @example + * cancelToken: "xyz789" + * + * cancelEmailChange({ token: "xyz789" }) + */ + cancelToken: string + /** + * The URL that the user should visit to cancel the email change request. + * @example + * cancelToken: "xyz789" + * + * cancelURL: "https://example.com/auth/email/change/cancel?token=xyz789" + */ + cancelURL: string }) => Awaitable } @@ -629,7 +667,8 @@ export type RateLimiterConfig = Partial< | "resetPassword" | "changePassword" | "changeEmail" - | "verifyEmailChange", + | "verifyEmailChange" + | "cancelEmailChange", RateLimiterRule > >["rules"] diff --git a/packages/core/src/@types/entities.ts b/packages/core/src/@types/entities.ts index d376d4b4..07ea1635 100644 --- a/packages/core/src/@types/entities.ts +++ b/packages/core/src/@types/entities.ts @@ -38,7 +38,12 @@ export type MFAChallengeStatus = "pending" | "verified" | "expired" | "failed" export type MfaCredentialStatus = "active" | "pending" | "disabled" -export type VerificationTokenType = "email_verification" | "password_reset" | "email_change" | "mfa_recovery" +export type VerificationTokenType = + | "email_verification" + | "password_reset" + | "email_change" + | "mfa_recovery" + | "email_change_cancel" export interface UserEntity { id: string diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 55ccc361..83403fdb 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -365,13 +365,16 @@ export interface SessionStrategy { changePassword(userId: string, currentPassword: string, newPassword: string): Promise - changeEmail(options: { userId: string; currentPassword: string; newEmail: string; origin: string }): Promise<{ - token: string - url: string - oldEmail?: string - }> + changeEmail(options: { + userId: string + currentPassword: string + newEmail: string + origin: string + }): Promise<{ email: string; confirmToken: string; confirmURL: string; cancelToken: string; cancelURL: string }> verifyEmailChange(options: { token: string; userId: string; sessionId: string }): Promise + + cancelEmailChange(options: { token: string; userId?: string } | { token?: string; userId: string }): Promise } export interface OAuthTokenPayload { diff --git a/packages/core/src/actions/email/cancel.ts b/packages/core/src/actions/email/cancel.ts new file mode 100644 index 00000000..078e98f3 --- /dev/null +++ b/packages/core/src/actions/email/cancel.ts @@ -0,0 +1,44 @@ +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { cancelEmailChange } from "@/api/cancel-email-change.ts" +import { CancelEmailChangeResponseSchema, CancelEmailChangeSearchParamsSchema } from "@/shared/schemas/actions.ts" + +const cancelEmailChangeActionConfig = () => { + return createEndpointConfig({ + schemas: { + searchParams: CancelEmailChangeSearchParamsSchema, + response: CancelEmailChangeResponseSchema, + }, + hooks: { + onError: (ctx) => { + return ctx.json( + { + type: "VALIDATION", + code: "INVALID_VERIFICATION_TOKEN", + message: "Invalid or expired verification token", + }, + { status: 422 } + ) + }, + }, + }) +} + +export const cancelEmailChangeAction = () => { + return createEndpoint( + "GET", + "/email/change/cancel", + async (ctx) => { + const { toResponse } = await cancelEmailChange({ + ctx: ctx.context, + payload: ctx.searchParams.token ? { token: ctx.searchParams.token } : undefined, + request: ctx.request, + headers: ctx.request.headers, + redirect: ctx.searchParams.redirect, + redirectTo: ctx.searchParams.redirectTo, + skipCSRFCheck: false, + }) + return toResponse() + }, + cancelEmailChangeActionConfig() + ) +} diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 8de660a7..378d497e 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -18,3 +18,4 @@ export { resetPasswordAction } from "@/actions/password/reset.ts" export { changePasswordAction } from "@/actions/password/change.ts" export { changeEmailAction } from "@/actions/email/change.ts" export { verifyEmailChangeAction } from "@/actions/email/verify.ts" +export { cancelEmailChangeAction } from "@/actions/email/cancel.ts" diff --git a/packages/core/src/api/cancel-email-change.ts b/packages/core/src/api/cancel-email-change.ts new file mode 100644 index 00000000..84ce25de --- /dev/null +++ b/packages/core/src/api/cancel-email-change.ts @@ -0,0 +1,105 @@ +import { HeadersBuilder } from "@aura-stack/router" +import { secureApiHeaders, toStandardizedHeaders } from "@/shared/http/headers.ts" +import { createValidation, errorToLogMessage, handleApiError, resolveApiRedirect } from "@/shared/utils/api.ts" +import type { FunctionAPIContext } from "@/@types/internal.ts" +import type { CancelEmailChangeAPIOptions, CancelEmailChangeAPIReturn } from "@/@types/api.ts" + +export const cancelEmailChange = async ({ + ctx, + payload, + headers: headersInit, + request: requestInit, + redirectTo, + redirect: redirectInit = true, + skipCSRFCheck = false, + doubleSubmitToken = undefined, +}: FunctionAPIContext): Promise => { + const { logger, sessionStrategy } = ctx + try { + logger?.log("CANCEL_EMAIL_CHANGE_INITIATED", { + structuredData: { + skipCSRFCheck: skipCSRFCheck, + doubleSubmitToken: doubleSubmitToken ? "provided" : "not_provided", + hasToken: Boolean(payload?.token), + }, + }) + + const validation = createValidation(ctx, toStandardizedHeaders(headersInit ?? requestInit?.headers ?? {})) + .buildRequest(requestInit, "/email/change/cancel") + .verifyRateLimit("cancelEmailChange") + + if (payload?.token) { + const { request, rateLimit } = await validation.execute() + requestInit = requestInit ?? request + + if (rateLimit) { + return rateLimit + } + + await sessionStrategy.cancelEmailChange({ + token: payload?.token, + userId: undefined, + }) + } else { + const { request, rateLimit, userId } = await validation + .verifyCSRFToken(skipCSRFCheck, doubleSubmitToken) + .verifySession() + .execute() + requestInit = requestInit ?? request + + if (rateLimit) { + return rateLimit + } + + await sessionStrategy.cancelEmailChange({ + token: undefined, + userId: userId!, + }) + } + + const headers = new HeadersBuilder(secureApiHeaders) + const { redirect, redirectURL } = await resolveApiRedirect(ctx, requestInit, redirectInit, redirectTo, headers) + const headersList = headers.toHeaders() + + return { + success: true, + redirect, + redirectURL: redirectInit ? null : redirectURL, + headers: headersList, + toResponse: () => { + return Response.json( + { + success: true, + redirect, + redirectURL: redirect ? null : redirectURL, + }, + { headers: headersList, status: redirect ? 302 : 200 } + ) + }, + } as CancelEmailChangeAPIReturn + } catch (error) { + errorToLogMessage(error, "CANCEL_EMAIL_CHANGE_ERROR", logger) + const { errors, statusCode } = handleApiError( + error, + "CANCEL_EMAIL_CHANGE_ERROR", + "An error occurred while canceling the email change." + ) + return { + success: false, + error: errors, + redirect: false, + redirectURL: null, + headers: new Headers(secureApiHeaders), + toResponse: () => { + return Response.json( + { + success: false, + redirect: false, + redirectURL: null, + }, + { headers: secureApiHeaders, status: statusCode } + ) + }, + } + } +} diff --git a/packages/core/src/api/change-email.ts b/packages/core/src/api/change-email.ts index 3d831566..31a434b3 100644 --- a/packages/core/src/api/change-email.ts +++ b/packages/core/src/api/change-email.ts @@ -39,13 +39,20 @@ export const changeEmail = async ({ if (rateLimit) { return rateLimit } - const { token, url, oldEmail } = await sessionStrategy.changeEmail({ + const { email, confirmToken, confirmURL, cancelToken, cancelURL } = await sessionStrategy.changeEmail({ userId: userId!, origin: originURL, currentPassword: payload.currentPassword, newEmail: payload.newEmail, }) - await emailChange.sendEmailChangeVerification({ email: oldEmail, newEmail: payload.newEmail, token, url }) + await emailChange.sendEmailChangeVerification({ + email, + newEmail: payload.newEmail, + confirmToken, + confirmURL, + cancelToken, + cancelURL, + }) const headers = new HeadersBuilder(secureApiHeaders) const { redirect, redirectURL } = await resolveApiRedirect(ctx, request, redirectInit, redirectTo, headers) diff --git a/packages/core/src/api/create-api.ts b/packages/core/src/api/create-api.ts index 96cb0d5c..4680b5d7 100644 --- a/packages/core/src/api/create-api.ts +++ b/packages/core/src/api/create-api.ts @@ -18,6 +18,7 @@ import { changePassword, changeEmail, verifyEmailChange, + cancelEmailChange, } from "@/api/index.ts" import type { GlobalContext, InferSchema } from "@aura-stack/router" import type { @@ -64,6 +65,8 @@ import type { ChangeEmailAPIReturn, VerifyEmailChangeAPIOptions, VerifyEmailChangeAPIReturn, + CancelEmailChangeAPIOptions, + CancelEmailChangeAPIReturn, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SchemaTypes } from "@/identity/index.ts" @@ -409,6 +412,33 @@ export const createAuthAPI = => { return verifyEmailChange({ ctx, skipCSRFCheck: true, ...options }) }, + /** + * Cancels an email change request on the server-side. It supports two modes of operation: + * - passing a `payload.token` to cancel the email change request directly (e.g., from a link in an email) + * - without a token, in which case it cancels the email change request for the currently authenticated user. + * + * > **NOTE**: This method is intended for authenticated users who have requested an email change and + * received a cancellation token or want to cancel the email change request for their own account. + * + * @param options - Options for the API call, including the cancellation token (if applicable), headers, and request object. + * @returns The object returned by the API call {@link CancelEmailChangeAPIReturn} + * @example + * // Cancel email change via token (e.g., from a link in an email) + * const response = await api.cancelEmailChange({ + * payload: { + * token: "email-change-cancellation-token-here" + * }, + * headers: getHeaders() + * }) + * + * // Cancel email change via application (for the currently authenticated user) + * const response = await api.cancelEmailChange({ + * headers: getHeaders() + * }) + */ + cancelEmailChange: async (options: CancelEmailChangeAPIOptions): Promise => { + return cancelEmailChange({ ctx, skipCSRFCheck: true, ...options }) + }, /** * Signs out the current session on the server-side. It implements CSRF Protection by default, for * server-side calls it only verifies and validates the CSRF Token, it also provides Double-Submit diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 52527639..cd7cdee2 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -18,3 +18,4 @@ export { resetPassword } from "@/api/reset-password.ts" export { changePassword } from "@/api/change-password.ts" export { changeEmail } from "@/api/change-email.ts" export { verifyEmailChange } from "@/api/verify-email-change.ts" +export { cancelEmailChange } from "@/api/cancel-email-change.ts" diff --git a/packages/core/src/config/router.ts b/packages/core/src/config/router.ts index 02a6e251..bb31e01d 100644 --- a/packages/core/src/config/router.ts +++ b/packages/core/src/config/router.ts @@ -20,6 +20,7 @@ import { changePasswordAction, changeEmailAction, verifyEmailChangeAction, + cancelEmailChangeAction, } from "@/actions/index.ts" import { createAuthAPI } from "@/api/index.ts" import { createContext } from "@/config/context.ts" @@ -77,6 +78,7 @@ export const createAuthInstance = < changePasswordAction(), changeEmailAction(), verifyEmailChangeAction(), + cancelEmailChangeAction(), ], config ) diff --git a/packages/core/src/errors/catalog.ts b/packages/core/src/errors/catalog.ts index 96f40dad..293dd551 100644 --- a/packages/core/src/errors/catalog.ts +++ b/packages/core/src/errors/catalog.ts @@ -978,6 +978,13 @@ export const ERROR_CATALOG = { message: "The provided email value is invalid. It must be a non-empty string and a valid email format.", userMessage: "The provided email address is invalid. Please enter a valid email address.", }, + USER_EMAIL_NOT_FOUND: { + type: "AUTH_FLOW", + statusCode: 404, + name: "AuthError", + message: "The user does not have an email address associated with their account.", + userMessage: "User email not found. Please contact support.", + }, } satisfies Record export type AuraErrorCode = keyof typeof ERROR_CATALOG diff --git a/packages/core/src/session/stateful/cancel-email-change.ts b/packages/core/src/session/stateful/cancel-email-change.ts new file mode 100644 index 00000000..b48c718b --- /dev/null +++ b/packages/core/src/session/stateful/cancel-email-change.ts @@ -0,0 +1,42 @@ +import { AuraAuthError } from "@/errors/aura-error.ts" +import { createHash } from "@/shared/crypto.ts" +import type { InternalStatefulContext } from "@/@types/internal.ts" + +export const cancelEmailChange = ({ ctx }: InternalStatefulContext) => { + const { sessionConfig } = ctx + return async ({ token, userId }: { token?: string; userId?: string }): Promise => { + const now = new Date() + if (token) { + const tokenHash = await createHash(token) + const verificationToken = await sessionConfig.adapter.getTokenByHash(tokenHash) + + if ( + !verificationToken || + verificationToken.type !== "email_change_cancel" || + verificationToken.expiresAt < now || + verificationToken.revokedAt + ) { + throw new AuraAuthError({ code: "INVALID_EMAIL_CHANGE_TOKEN" }) + } + const userTokens = await sessionConfig.adapter.getTokensByUserId(verificationToken.userId) + const pending = userTokens.filter( + (t) => + (t.type === "email_change" || t.type === "email_change_cancel") && + t.newIdentifier === verificationToken.newIdentifier && + !t.revokedAt && + t.expiresAt > now + ) + await Promise.all(pending.map((t) => sessionConfig.adapter.revokeToken(t.id))) + return + } + + if (!userId) { + throw new AuraAuthError({ code: "USER_NOT_FOUND" }) + } + const userIdTokens = await sessionConfig.adapter.getTokensByUserId(userId) + const pending = userIdTokens.filter( + (t) => (t.type === "email_change" || t.type === "email_change_cancel") && !t.revokedAt && t.expiresAt > now + ) + await Promise.all(pending.map((t) => sessionConfig.adapter.revokeToken(t.id))) + } +} diff --git a/packages/core/src/session/stateful/change-email.ts b/packages/core/src/session/stateful/change-email.ts index 2995063b..18c1290d 100644 --- a/packages/core/src/session/stateful/change-email.ts +++ b/packages/core/src/session/stateful/change-email.ts @@ -31,9 +31,11 @@ export const changeEmail = ({ ctx }: InternalStatefulContext): SessionStrategy[" if (!user) { throw new AuraAuthError({ code: "SILENT_USER_NOT_FOUND" }) } - - const token = createSecretValue() - const tokenHash = await createHash(token) + if (!user.email) { + throw new AuraAuthError({ code: "USER_EMAIL_NOT_FOUND" }) + } + const confirmToken = createSecretValue() + const confirmTokenHash = await createHash(confirmToken) const expiresIn = ctx.emailChange?.expiresIn ?? 1800 await sessionConfig.adapter.createToken({ userId, @@ -41,12 +43,25 @@ export const changeEmail = ({ ctx }: InternalStatefulContext): SessionStrategy[" identifier: user.email, newIdentifier: normalizedNewEmail, type: "email_change", - tokenHash, + tokenHash: confirmTokenHash, + }) + + const cancelToken = createSecretValue() + const cancelTokenHash = await createHash(cancelToken) + await sessionConfig.adapter.createToken({ + userId, + expiresAt: new Date(Date.now() + expiresIn * 1000), + identifier: user.email, + newIdentifier: newEmail, + type: "email_change_cancel", + tokenHash: cancelTokenHash, }) return { - token, - url: `${origin}${ctx.basePath}/email/change/verify?token=${token}`, - oldEmail: user.email ? user.email : undefined, + email: user.email, + confirmToken, + confirmURL: `${origin}${ctx.basePath}/email/change/verify?token=${confirmToken}`, + cancelToken, + cancelURL: `${origin}${ctx.basePath}/email/change/cancel?token=${cancelToken}`, } } } diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts index 8cd5f31c..13236ac5 100644 --- a/packages/core/src/session/stateful/index.ts +++ b/packages/core/src/session/stateful/index.ts @@ -16,6 +16,7 @@ import { refreshUserInfo } from "@/session/stateful/refresh-userInfo.ts" import { signInCredentials } from "@/session/stateful/sign-in-credentials.ts" import { getProviderTokens } from "@/session/stateful/get-provider-tokens.ts" import { verifyEmailChange } from "@/session/stateful/verify-email-change.ts" +import { cancelEmailChange } from "@/session/stateful/cancel-email-change.ts" import { isProviderConnected } from "@/session/stateful/is-provider-connected.ts" import { sendVerificationEmail } from "@/session/stateful/send-verification-email.ts" import type { SessionStrategy, User } from "@/@types/index.ts" @@ -41,6 +42,7 @@ export const createStatefulStrategy = ( changePassword: changePassword(ctx), refreshUserInfo: refreshUserInfo(ctx), verifyEmailChange: verifyEmailChange(ctx), + cancelEmailChange: cancelEmailChange(ctx), signInCredentials: signInCredentials(ctx), getProviderTokens: getProviderTokens(ctx), isProviderConnected: isProviderConnected(ctx), diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts index 8b6cfb9d..5141bd54 100644 --- a/packages/core/src/session/stateless/index.ts +++ b/packages/core/src/session/stateless/index.ts @@ -19,6 +19,7 @@ import { changePassword, changeEmail, verifyEmailChange, + cancelEmailChange, } from "@/session/stateless/unsupported.ts" import type { SessionStrategy, User } from "@/@types/index.ts" import type { InternalStatelessContext } from "@/@types/internal.ts" @@ -43,6 +44,7 @@ export const createStatelessStrategy = ( destroySession: destroySession(ctx), refreshUserInfo: refreshUserInfo(ctx), verifyEmailChange: verifyEmailChange(ctx), + cancelEmailChange: cancelEmailChange(ctx), getProviderTokens: getProviderTokens(ctx), signInCredentials: signInCredentials(ctx), isProviderConnected: isProviderConnected(ctx), diff --git a/packages/core/src/session/stateless/unsupported.ts b/packages/core/src/session/stateless/unsupported.ts index 33da0f3a..0b0d9b51 100644 --- a/packages/core/src/session/stateless/unsupported.ts +++ b/packages/core/src/session/stateless/unsupported.ts @@ -67,3 +67,9 @@ export const verifyEmailChange = (_: InternalStatelessContext): SessionStrategy[ throw new Error("verifyEmailChange is not supported in stateless mode.") } } + +export const cancelEmailChange = (_: InternalStatelessContext) => { + return async (_options: {}): Promise => { + throw new Error("cancelEmailChange is not supported in stateless mode.") + } +} diff --git a/packages/core/src/shared/rate-limiter.ts b/packages/core/src/shared/rate-limiter.ts index 274a8c35..81b419ab 100644 --- a/packages/core/src/shared/rate-limiter.ts +++ b/packages/core/src/shared/rate-limiter.ts @@ -131,6 +131,13 @@ export const createRateLimiterInstance = (config?: RateLimiterConfig, useProxyHe keyGenerator: (request) => getLimitKey(request, "verifyEmailChange"), ...config?.verifyEmailChange, } as RateLimiterRule, + cancelEmailChange: { + algorithm: "fixed-window", + limit: 5, + windowMs: 60 * 60 * 1000, + keyGenerator: (request) => getLimitKey(request, "cancelEmailChange"), + ...config?.cancelEmailChange, + } as RateLimiterRule, }, }) } diff --git a/packages/core/src/shared/schemas/actions.ts b/packages/core/src/shared/schemas/actions.ts index 21b5bafa..beacfd10 100644 --- a/packages/core/src/shared/schemas/actions.ts +++ b/packages/core/src/shared/schemas/actions.ts @@ -195,3 +195,13 @@ export const VerifyEmailChangeResponseSchema = object({ redirect: boolean(), redirectURL: string().nullable(), }) + +export const CancelEmailChangeSearchParamsSchema = RedirectOptionsSchema.extend({ + token: string().min(32).optional(), +}) + +export const CancelEmailChangeResponseSchema = object({ + success: boolean(), + redirect: boolean(), + redirectURL: string().nullable(), +}) diff --git a/packages/core/test/actions/email/cancel.test.ts b/packages/core/test/actions/email/cancel.test.ts new file mode 100644 index 00000000..1f22ece2 --- /dev/null +++ b/packages/core/test/actions/email/cancel.test.ts @@ -0,0 +1,314 @@ +import { describe, test, expect, vi } from "vitest" +import { createCSRF, createHash, createSecretValue } from "@/shared/crypto.ts" +import { authInstance, jose } from "@test/setup/auth.ts" +import { emailChangeCancelTokenEntity, sessionEntityWithUser } from "@test/setup/entities.ts" + +describe("cancelEmailChangeAction", async () => { + const csrfToken = await createCSRF(jose) + + describe("token-based flow", () => { + test("token-based flow: success", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = createSecretValue() + const tokenHash = await createHash(token) + + const response = await GET(new Request(`https://example.com/auth/email/change/cancel?token=${token}`)) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("token-based flow: invalid token", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(null) + const getTokensByUserIdMock = vi.fn().mockReturnValue([]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = "invalid-token" + + const response = await GET(new Request(`https://example.com/auth/email/change/cancel?token=${token}`)) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + type: "VALIDATION", + code: "INVALID_VERIFICATION_TOKEN", + message: "Invalid or expired verification token", + }) + + expect(getTokenByHashMock).not.toHaveBeenCalled() + expect(getTokensByUserIdMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("token-based flow with redirectTo and redirect=true", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = createSecretValue() + const tokenHash = await createHash(token) + + const response = await GET( + new Request(`https://example.com/auth/email/change/cancel?token=${token}&redirectTo=/settings&redirect=true`, { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: "__Host-aura-auth.csrf_token=${csrfToken}", + }, + }) + ) + + expect(response.status).toBe(302) + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("token-based flow with redirectTo and redirect=false", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = createSecretValue() + const tokenHash = await createHash(token) + + const response = await GET( + new Request(`https://example.com/auth/email/change/cancel?token=${token}&redirectTo=/settings&redirect=false`, { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: "__Host-aura-auth.csrf_token=${csrfToken}", + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/settings", + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + }) + + describe("in-app flow", () => { + test("in-app flow: success", async () => { + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const response = await GET( + new Request("https://example.com/auth/email/change/cancel", { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("in-app flow with missing csrf token", async () => { + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const response = await GET( + new Request("https://example.com/auth/email/change/cancel", { + method: "GET", + headers: { + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + + expect(getTokensByUserIdMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("in-app flow: no pending email change", async () => { + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const response = await GET( + new Request("https://example.com/auth/email/change/cancel", { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("in-app flow with redirectTo and redirect=true", async () => { + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const response = await GET( + new Request("https://example.com/auth/email/change/cancel?redirectTo=/settings&redirect=true", { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(302) + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("in-app flow with redirectTo and redirect=false", async () => { + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { + handlers: { GET }, + } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const response = await GET( + new Request("https://example.com/auth/email/change/cancel?redirectTo=/settings&redirect=false", { + method: "GET", + headers: { + "X-CSRF-Token": csrfToken, + Cookie: `__Secure-aura-auth.session_token=valid-token; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/settings", + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + }) +}) diff --git a/packages/core/test/actions/email/change.test.ts b/packages/core/test/actions/email/change.test.ts index 6fbed635..aacfbfe7 100644 --- a/packages/core/test/actions/email/change.test.ts +++ b/packages/core/test/actions/email/change.test.ts @@ -36,9 +36,15 @@ describe("changeEmailAction", async () => { const sessionToken = "valid-token" const sessionTokenHash = await createHash(sessionToken) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) const response = await POST( new Request("https://example.com/auth/email/change", { @@ -67,19 +73,30 @@ describe("changeEmailAction", async () => { expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") expect(getUserByIdMock).toHaveBeenCalledWith("user-123") - expect(createTokenMock).toHaveBeenCalledWith({ + expect(createTokenMock).toHaveBeenNthCalledWith(1, { userId: "user-123", - expiresAt: expect.any(Date), identifier: "john@example.com", newIdentifier: "new@example.com", type: "email_change", - tokenHash, + expiresAt: expect.any(Date), + tokenHash: confirmTokenHash, }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, + }) + expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ email: "john@example.com", newEmail: "new@example.com", - token, - url: `https://example.com/auth/email/change/verify?token=${token}`, + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, }) }) @@ -110,8 +127,8 @@ describe("changeEmailAction", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const response = await POST( new Request("https://example.com/auth/email/change", { @@ -119,7 +136,7 @@ describe("changeEmailAction", async () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `__Secure-aura-auth.session_token=${token}; __Host-aura-auth.csrf_token=${csrfToken}`, + Cookie: `__Secure-aura-auth.session_token=${sessionToken}; __Host-aura-auth.csrf_token=${csrfToken}`, }, body: JSON.stringify({ currentPassword: "current-password", @@ -135,7 +152,7 @@ describe("changeEmailAction", async () => { redirectURL: null, }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).not.toHaveBeenCalled() expect(getUserByEmailMock).not.toHaveBeenCalled() @@ -171,8 +188,8 @@ describe("changeEmailAction", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const response = await POST( new Request("https://example.com/auth/email/change", { @@ -180,7 +197,7 @@ describe("changeEmailAction", async () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `__Secure-aura-auth.session_token=${token}; __Host-aura-auth.csrf_token=${csrfToken}`, + Cookie: `__Secure-aura-auth.session_token=${sessionToken}; __Host-aura-auth.csrf_token=${csrfToken}`, }, body: JSON.stringify({ currentPassword: "wrong-password", @@ -196,7 +213,7 @@ describe("changeEmailAction", async () => { redirectURL: null, }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).not.toHaveBeenCalled() @@ -232,8 +249,8 @@ describe("changeEmailAction", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const response = await POST( new Request("https://example.com/auth/email/change", { @@ -241,7 +258,7 @@ describe("changeEmailAction", async () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `__Secure-aura-auth.session_token=${token}; __Host-aura-auth.csrf_token=${csrfToken}`, + Cookie: `__Secure-aura-auth.session_token=${sessionToken}; __Host-aura-auth.csrf_token=${csrfToken}`, }, body: JSON.stringify({ currentPassword: "current-password", @@ -257,7 +274,7 @@ describe("changeEmailAction", async () => { redirectURL: null, }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") @@ -296,9 +313,15 @@ describe("changeEmailAction", async () => { const sessionToken = "valid-session-token" const sessionTokenHash = await createHash(sessionToken) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) const response = await POST( new Request("https://example.com/auth/email/change?redirectTo=/auth/success", { @@ -327,19 +350,30 @@ describe("changeEmailAction", async () => { expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") expect(getUserByIdMock).toHaveBeenCalledWith("user-123") - expect(createTokenMock).toHaveBeenCalledWith({ + expect(createTokenMock).toHaveBeenNthCalledWith(1, { userId: "user-123", - expiresAt: expect.any(Date), identifier: "john@example.com", newIdentifier: "new@example.com", type: "email_change", - tokenHash, + expiresAt: expect.any(Date), + tokenHash: confirmTokenHash, }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, + }) + expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ email: "john@example.com", newEmail: "new@example.com", - token, - url: `https://example.com/auth/email/change/verify?token=${token}`, + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, }) }) @@ -373,9 +407,15 @@ describe("changeEmailAction", async () => { const sessionToken = "valid-session-token" const sessionTokenHash = await createHash(sessionToken) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) const response = await POST( new Request("https://example.com/auth/email/change?redirectTo=/auth/success&redirect=false", { @@ -404,19 +444,30 @@ describe("changeEmailAction", async () => { expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") expect(getUserByIdMock).toHaveBeenCalledWith("user-123") - expect(createTokenMock).toHaveBeenCalledWith({ + expect(createTokenMock).toHaveBeenNthCalledWith(1, { userId: "user-123", - expiresAt: expect.any(Date), identifier: "john@example.com", newIdentifier: "new@example.com", type: "email_change", - tokenHash, + expiresAt: expect.any(Date), + tokenHash: confirmTokenHash, + }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, }) + expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ email: "john@example.com", newEmail: "new@example.com", - token, - url: `https://example.com/auth/email/change/verify?token=${token}`, + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, }) }) }) diff --git a/packages/core/test/api/stateful/cancel-email-change.test.ts b/packages/core/test/api/stateful/cancel-email-change.test.ts new file mode 100644 index 00000000..e5baab16 --- /dev/null +++ b/packages/core/test/api/stateful/cancel-email-change.test.ts @@ -0,0 +1,246 @@ +import { describe, test, expect, vi } from "vitest" +import { createCSRF, createHash } from "@/shared/crypto.ts" +import { authInstance, jose } from "@test/setup/auth.ts" +import { emailChangeCancelTokenEntity, emailChangeTokenEntity, sessionEntityWithUser } from "@test/setup/entities.ts" + +describe("cancelEmailChange API", async () => { + const csrfToken = await createCSRF(jose) + + test("token-based flow: success", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = "valid-token" + const tokenHash = await createHash(token) + + const output = await api.cancelEmailChange({ + payload: { token }, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("token-based flow: invalid token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(null) + const getTokensByUserIdMock = vi.fn().mockReturnValue([]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.cancelEmailChange({ + payload: { token: "invalid-token" }, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "INVALID_EMAIL_CHANGE_TOKEN", + message: "Invalid or expired email change token.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalled() + expect(getTokensByUserIdMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("in-app flow: success", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeTokenEntity]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.cancelEmailChange({ + headers: { + Cookie: `aura-auth.session_token=valid-token; aura-auth.csrf_token=${csrfToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-456") + }) + + test("in-app flow: no pending email change", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.cancelEmailChange({ + headers: { + Cookie: `aura-auth.session_token=valid-token; aura-auth.csrf_token=${csrfToken}`, + }, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("in-app flow with redirect", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeTokenEntity]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getSessionByToken: getSessionByTokenMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.cancelEmailChange({ + headers: { + Cookie: `aura-auth.session_token=valid-token; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: true, + redirectTo: "https://example.com/settings", + }) + + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-456") + }) + + test("token-based flow: success with redirectTo and redirect: false", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = "valid-token" + const tokenHash = await createHash(token) + + const output = await api.cancelEmailChange({ + payload: { token }, + headers: { + Cookie: `aura-auth.session_token=valid-token; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + redirectTo: "https://example.com/settings", + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/settings", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) + + test("token-based flow: success with redirectTo and redirect: true", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(emailChangeCancelTokenEntity) + const getTokensByUserIdMock = vi.fn().mockReturnValue([emailChangeCancelTokenEntity]) + const revokeTokenMock = vi.fn() + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getTokensByUserId: getTokensByUserIdMock, + revokeToken: revokeTokenMock, + }) + + const token = "valid-token" + const tokenHash = await createHash(token) + + const output = await api.cancelEmailChange({ + payload: { token }, + headers: { + Cookie: `aura-auth.session_token=valid-token; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: true, + redirectTo: "https://example.com/settings", + }) + + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getTokensByUserIdMock).toHaveBeenCalledWith("user-123") + expect(revokeTokenMock).toHaveBeenCalledWith("token-789") + }) +}) diff --git a/packages/core/test/api/stateful/change-email.test.ts b/packages/core/test/api/stateful/change-email.test.ts index f5dd6906..fca07ce7 100644 --- a/packages/core/test/api/stateful/change-email.test.ts +++ b/packages/core/test/api/stateful/change-email.test.ts @@ -33,9 +33,18 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) const output = await api.changeEmail({ payload: { @@ -43,7 +52,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -55,24 +64,34 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByIdMock).toHaveBeenCalledWith("user-123") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") - expect(createTokenMock).toHaveBeenCalledWith({ + expect(createTokenMock).toHaveBeenNthCalledWith(1, { userId: "user-123", identifier: "john@example.com", newIdentifier: "new@example.com", type: "email_change", expiresAt: expect.any(Date), - tokenHash, + tokenHash: confirmTokenHash, + }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, }) expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ email: "john@example.com", newEmail: "new@example.com", - token, - url: `https://example.com/auth/email/change/verify?token=${token}`, + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, }) }) @@ -103,9 +122,8 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const output = await api.changeEmail({ payload: { @@ -113,7 +131,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -129,7 +147,7 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).not.toHaveBeenCalled() expect(getUserByIdMock).not.toHaveBeenCalled() @@ -165,9 +183,8 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const output = await api.changeEmail({ payload: { @@ -175,7 +192,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -191,7 +208,7 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByIdMock).not.toHaveBeenCalled() @@ -228,9 +245,8 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const output = await api.changeEmail({ payload: { @@ -238,7 +254,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -254,7 +270,7 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") @@ -290,9 +306,8 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) const output = await api.changeEmail({ payload: { @@ -300,7 +315,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -316,7 +331,7 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") @@ -352,9 +367,18 @@ describe("changeEmail API", async () => { } ) - const token = "valid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) const output = await api.changeEmail({ payload: { @@ -362,7 +386,7 @@ describe("changeEmail API", async () => { newEmail: "new@example.com", }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, redirect: true, redirectTo: "https://example.com/success", @@ -376,17 +400,125 @@ describe("changeEmail API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") expect(getUserByIdMock).toHaveBeenCalledWith("user-123") - expect(createTokenMock).toHaveBeenCalled() + expect(createTokenMock).toHaveBeenNthCalledWith(1, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change", + expiresAt: expect.any(Date), + tokenHash: confirmTokenHash, + }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, + }) + expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ + email: "john@example.com", + newEmail: "new@example.com", + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, + }) + }) + + test("success changeEmail flow with redirectTo and redirect: false", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getSessionByTokenMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([accountEntityWithCredentials]) + const getCredentialAccountMock = vi.fn().mockReturnValue(credentialAccountEntity) + const getUserByIdMock = vi.fn().mockReturnValue(userEntity) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createTokenMock = vi.fn() + const sendEmailChangeVerificationMock = vi.fn() + + const { api } = authInstance( + { + getSessionByToken: getSessionByTokenMock, + getAccountsByUserId: getAccountsByUserIdMock, + getCredentialAccount: getCredentialAccountMock, + getUserById: getUserByIdMock, + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + emailChange: { + sendEmailChangeVerification: sendEmailChangeVerificationMock, + }, + } + ) + + const sessionToken = "valid-session-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "valid-token" + const confirmTokenHash = await createHash(confirmToken) + + const cancelToken = "valid-cancel-token" + const cancelTokenHash = await createHash(cancelToken) + + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue") + .mockReturnValueOnce(confirmToken) + .mockReturnValueOnce(cancelToken) + + const output = await api.changeEmail({ + payload: { + currentPassword: "current-password", + newEmail: "new@example.com", + }, + headers: { + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, + }, + redirect: false, + redirectTo: "https://example.com/success", + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/success", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(getCredentialAccountMock).toHaveBeenCalledWith("account-321") + expect(getUserByEmailMock).toHaveBeenCalledWith("new@example.com") + expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(createTokenMock).toHaveBeenNthCalledWith(1, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change", + expiresAt: expect.any(Date), + tokenHash: confirmTokenHash, + }) + expect(createTokenMock).toHaveBeenNthCalledWith(2, { + userId: "user-123", + identifier: "john@example.com", + newIdentifier: "new@example.com", + type: "email_change_cancel", + expiresAt: expect.any(Date), + tokenHash: cancelTokenHash, + }) expect(sendEmailChangeVerificationMock).toHaveBeenCalledWith({ email: "john@example.com", newEmail: "new@example.com", - token, - url: `https://example.com/auth/email/change/verify?token=${token}`, + confirmToken: confirmToken, + confirmURL: `https://example.com/auth/email/change/verify?token=${confirmToken}`, + cancelToken: cancelToken, + cancelURL: `https://example.com/auth/email/change/cancel?token=${cancelToken}`, }) }) }) diff --git a/packages/core/test/api/stateful/verify-email-change.test.ts b/packages/core/test/api/stateful/verify-email-change.test.ts index eae43c16..db01cab4 100644 --- a/packages/core/test/api/stateful/verify-email-change.test.ts +++ b/packages/core/test/api/stateful/verify-email-change.test.ts @@ -23,14 +23,16 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "mocked-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "mocked-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "mocked-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -42,7 +44,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).toHaveBeenCalledWith("user-123", { email: "new@example.com", emailVerifiedAt: expect.any(Date), @@ -68,13 +71,15 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "invalid-token" - const tokenHash = await createHash(token) + const sessionToken = "session-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "invalid-confirm-token" const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -90,7 +95,7 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) expect(getTokenByHashMock).not.toHaveBeenCalled() expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() @@ -119,14 +124,16 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "invalid-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "session-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "revoked-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -142,8 +149,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() expect(revokeAllSessionsMock).not.toHaveBeenCalled() @@ -171,14 +178,16 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "mocked-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "mocked-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "wrong-type-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -194,8 +203,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() expect(revokeAllSessionsMock).not.toHaveBeenCalled() @@ -222,14 +231,16 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "mocked-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "mocked-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "expired-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -245,8 +256,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() expect(revokeAllSessionsMock).not.toHaveBeenCalled() @@ -273,13 +284,15 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "mocked-token" - const tokenHash = await createHash(token) + const sessionToken = "mocked-token" + const sessionTokenHash = await createHash(sessionToken) + const confirmToken = "expired-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -295,8 +308,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() expect(revokeAllSessionsMock).not.toHaveBeenCalled() @@ -323,14 +336,16 @@ describe("verifyEmailChange API", async () => { revokeAllSessions: revokeAllSessionsMock, }) - const token = "mocked-token" - const tokenHash = await createHash(token) - vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + const sessionToken = "mocked-token" + const sessionTokenHash = await createHash(sessionToken) + + const confirmToken = "expired-confirm-token" + const confirmTokenHash = await createHash(confirmToken) const output = await api.verifyEmailChange({ - payload: { token }, + payload: { token: confirmToken }, headers: { - Cookie: `aura-auth.session_token=${token}; aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.session_token=${sessionToken}; aura-auth.csrf_token=${csrfToken}`, }, }) @@ -346,8 +361,8 @@ describe("verifyEmailChange API", async () => { toResponse: expect.any(Function), }) - expect(getSessionByTokenMock).toHaveBeenCalledWith(tokenHash) - expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getSessionByTokenMock).toHaveBeenCalledWith(sessionTokenHash) + expect(getTokenByHashMock).toHaveBeenCalledWith(confirmTokenHash) expect(updateUserMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() expect(revokeAllSessionsMock).not.toHaveBeenCalled() diff --git a/packages/core/test/setup/entities.ts b/packages/core/test/setup/entities.ts index 1ef63ec8..543a49c9 100644 --- a/packages/core/test/setup/entities.ts +++ b/packages/core/test/setup/entities.ts @@ -155,3 +155,27 @@ export const emailChangeTokenEntity: VerificationTokenEntity = { revokedAt: null, createdAt: new Date(), } + +export const emailChangeCancelTokenEntity: VerificationTokenEntity = { + id: "token-789", + userId: "user-123", + tokenHash: "hashed-email-change-cancel-token", + type: "email_change_cancel", + identifier: "john@example.com", + newIdentifier: "new@example.com", + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + revokedAt: null, + createdAt: new Date(), +} + +export const expiredEmailChangeTokenEntity: VerificationTokenEntity = { + id: "token-789", + userId: "user-123", + tokenHash: "hashed-expired-email-change-token", + type: "email_change", + identifier: "old@example.com", + newIdentifier: "new@example.com", + expiresAt: new Date(Date.now() - 1000), + revokedAt: null, + createdAt: new Date(), +} diff --git a/packages/elysia/prisma/migrations/20261023220746_init/migration.sql b/packages/elysia/prisma/migrations/20261029004556_init/migration.sql similarity index 99% rename from packages/elysia/prisma/migrations/20261023220746_init/migration.sql rename to packages/elysia/prisma/migrations/20261029004556_init/migration.sql index d35ba148..8f822c41 100644 --- a/packages/elysia/prisma/migrations/20261023220746_init/migration.sql +++ b/packages/elysia/prisma/migrations/20261029004556_init/migration.sql @@ -26,7 +26,7 @@ CREATE TYPE "MFAChallengeStatus" AS ENUM ('pending', 'verified', 'expired', 'fai CREATE TYPE "MFACredentialStatus" AS ENUM ('active', 'pending', 'disabled'); -- CreateEnum -CREATE TYPE "VerificationTokenType" AS ENUM ('email_verification', 'password_reset', 'email_change', 'mfa_recovery'); +CREATE TYPE "VerificationTokenType" AS ENUM ('email_verification', 'password_reset', 'email_change', 'mfa_recovery', 'email_change_cancel'); -- CreateTable CREATE TABLE "User" ( diff --git a/packages/elysia/prisma/schema.prisma b/packages/elysia/prisma/schema.prisma index 2a97b3a6..5abc8652 100644 --- a/packages/elysia/prisma/schema.prisma +++ b/packages/elysia/prisma/schema.prisma @@ -80,6 +80,7 @@ enum VerificationTokenType { password_reset email_change mfa_recovery + email_change_cancel } /// --------------------------------------------------------------------------- diff --git a/packages/prisma/prisma/migrations/20261023220237_init/migration.sql b/packages/prisma/prisma/migrations/20261028214604_init/migration.sql similarity index 99% rename from packages/prisma/prisma/migrations/20261023220237_init/migration.sql rename to packages/prisma/prisma/migrations/20261028214604_init/migration.sql index d35ba148..8f822c41 100644 --- a/packages/prisma/prisma/migrations/20261023220237_init/migration.sql +++ b/packages/prisma/prisma/migrations/20261028214604_init/migration.sql @@ -26,7 +26,7 @@ CREATE TYPE "MFAChallengeStatus" AS ENUM ('pending', 'verified', 'expired', 'fai CREATE TYPE "MFACredentialStatus" AS ENUM ('active', 'pending', 'disabled'); -- CreateEnum -CREATE TYPE "VerificationTokenType" AS ENUM ('email_verification', 'password_reset', 'email_change', 'mfa_recovery'); +CREATE TYPE "VerificationTokenType" AS ENUM ('email_verification', 'password_reset', 'email_change', 'mfa_recovery', 'email_change_cancel'); -- CreateTable CREATE TABLE "User" ( diff --git a/packages/prisma/prisma/schema.prisma b/packages/prisma/prisma/schema.prisma index 2a97b3a6..5abc8652 100644 --- a/packages/prisma/prisma/schema.prisma +++ b/packages/prisma/prisma/schema.prisma @@ -80,6 +80,7 @@ enum VerificationTokenType { password_reset email_change mfa_recovery + email_change_cancel } /// --------------------------------------------------------------------------- diff --git a/packages/prisma/src/adapter.ts b/packages/prisma/src/adapter.ts index 62beb6b0..2c453878 100644 --- a/packages/prisma/src/adapter.ts +++ b/packages/prisma/src/adapter.ts @@ -536,6 +536,12 @@ export const prismaAdapter = ({ client, deleteStrategy = "soft" }: PrismaAdapter data: { revokedAt: new Date() }, }) }, + getTokensByUserId: async (userId) => { + const tokens = await client.verificationToken.findMany({ + where: { userId }, + }) + return tokens.map(toVerificationToken) + }, /** * OAuth Transactions */