From dcd9fe4c515426eeda70e423f9afe73f8d5db3ff Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 24 Oct 2026 12:13:08 -0500 Subject: [PATCH 1/6] feat(core): add forgotPassword and resetPassword APIs --- packages/core/CHANGELOG.md | 2 + packages/core/src/@types/api.ts | 26 +++++++ packages/core/src/@types/config.ts | 34 +++++++- packages/core/src/@types/internal.ts | 2 + packages/core/src/@types/session.ts | 10 +++ packages/core/src/actions/index.ts | 2 + packages/core/src/actions/password/forgot.ts | 31 ++++++++ packages/core/src/actions/password/reset.ts | 32 ++++++++ packages/core/src/api/create-api.ts | 38 +++++++++ packages/core/src/api/forgot-password.ts | 78 +++++++++++++++++++ packages/core/src/api/index.ts | 2 + packages/core/src/api/reset-password.ts | 76 ++++++++++++++++++ packages/core/src/config/logger/catalog.ts | 36 +++++++++ packages/core/src/config/router.ts | 4 + packages/core/src/errors/catalog.ts | 14 ++++ .../src/session/stateful/forgot-password.ts | 25 ++++++ packages/core/src/session/stateful/index.ts | 6 +- .../src/session/stateful/reset-password.ts | 35 +++++++++ packages/core/src/session/stateless/index.ts | 10 ++- .../core/src/session/stateless/unsupported.ts | 12 +++ packages/core/src/shared/rate-limiter.ts | 21 +++++ packages/core/src/shared/schemas/actions.ts | 21 +++++ 22 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/actions/password/forgot.ts create mode 100644 packages/core/src/actions/password/reset.ts create mode 100644 packages/core/src/api/forgot-password.ts create mode 100644 packages/core/src/api/reset-password.ts create mode 100644 packages/core/src/session/stateful/forgot-password.ts create mode 100644 packages/core/src/session/stateful/reset-password.ts diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 8186c00b..b7330f3b 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 `forgotPassword()` and `resetPassword()` server API functions, along with the `POST /password/forgot` and `POST /password/reset` endpoints for client-side account recovery. These APIs support password recovery via email or SMS. [#292](https://github.com/aura-stack-ts/auth/pull/292) + - Added the `sendVerificationEmail()` and `verifyEmail()` server API functions, along with the `POST /emailVerification/send` and `POST /emailVerification/verify` endpoints for client-side email verification. These APIs support email verification during sign-up and lazy verification after sign-in. [#291](https://github.com/aura-stack-ts/auth/pull/291) - Added support for configuring `baseURL` through the `BASE_URL` environment variable, in addition to passing it directly to `createAuthClient()`. [#286](https://github.com/aura-stack-ts/auth/pull/286) diff --git a/packages/core/src/@types/api.ts b/packages/core/src/@types/api.ts index 157f2e07..fa728b7b 100644 --- a/packages/core/src/@types/api.ts +++ b/packages/core/src/@types/api.ts @@ -397,3 +397,29 @@ export type VerifyEmailAPIOptions = APIOptionsWithRequest & APIOptionsWithRedire export type VerifyEmailData = APIFunctionReturnData export type VerifyEmailAPIReturn = AuthActionAPIReturn + +export type ForgotPasswordAPIOptions = APIOptionsWithRequest & APIOptionsWithRedirectTo + +export type ForgotPasswordData = + | { 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 ForgotPasswordAPIReturn = AuthActionAPIReturn + +export type ResetPasswordAPIOptions = APIOptionsWithRequest & + APIOptionsWithRedirectTo & { + payload: { + token: string + newPassword: string + } + } + +export type ResetPasswordData = + | { 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 ResetPasswordAPIReturn = AuthActionAPIReturn diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 7b7bd573..77ebbcff 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -206,6 +206,22 @@ export type AuthConfig< * } */ emailVerification?: EmailVerificationConfig + /** + * Password reset configuration. It includes the expiration time and the callback function to send the password reset email. + * @example + * passwordReset: { + * expiresIn: 3600, // 1 hour + * sendPasswordResetEmail: async ({ email, token, resetURL }) => { + * // Implement your email sending logic here + * sendEmail({ + * to: email, + * subject: "Password Reset", + * html: `

Click here to reset your password.

`, + * }) + * } + * } + */ + passwordReset?: PasswordResetConfig } & TrustedProxyHeadersConfig export type OAuthProvidersConfig = @@ -513,6 +529,19 @@ export interface EmailVerificationConfig { sendVerificationEmail?: (options: { email: string; token: string | null; url: string | null }) => Awaitable } +export interface PasswordResetConfig { + /** + * Defines the expiration time for the password reset token in seconds. By default, it is + * set to 30 minutes (1800 seconds). + */ + expiresIn?: number + /** + * Callback function to send the password reset email. It receives the email address, token, and reset URL. + * You must implement this function to send the email using your preferred email service. + */ + sendPasswordResetEmail?: (options: { email: string; token: string | null; url: string | null }) => Awaitable +} + // #region Rate Limiter export type RateLimiterConfig = Partial< @@ -530,7 +559,10 @@ export type RateLimiterConfig = Partial< | "isProviderConnected" | "signOut" | "sendVerificationEmail" - | "verifyEmail", + | "verifyEmail" + | "forgotPassword" + | "resetPassword" + | "changePassword", RateLimiterRule > >["rules"] diff --git a/packages/core/src/@types/internal.ts b/packages/core/src/@types/internal.ts index 0f783b7f..1cb9c923 100644 --- a/packages/core/src/@types/internal.ts +++ b/packages/core/src/@types/internal.ts @@ -19,6 +19,7 @@ import type { EmailVerificationConfig, JoseInstance, LogLevel, + PasswordResetConfig, RateLimiterConfig, SignUpConfig, TrustedOrigin, @@ -92,6 +93,7 @@ export interface RouterGlobalContext> sessionConfig: SessionConfig emailVerification?: EmailVerificationConfig + passwordReset?: PasswordResetConfig } export interface SchemaRegistryContext { diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index e666a304..bb00567a 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -355,6 +355,16 @@ export interface SessionStrategy { }> verifyEmail(options: { token: string }): Promise + + forgotPassword( + email: string, + request: Request + ): Promise<{ + token: string | null + url: string | null + }> + + resetPassword(options: { token: string; newPassword: string }): Promise } export interface OAuthTokenPayload { diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 45efb7b3..207149dd 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -13,3 +13,5 @@ export { disconnectAction } from "@/actions/providers/disconnect.ts" export { connectedAction } from "@/actions/providers/connected.ts" export { sendVerificationEmailAction } from "@/actions/verifyEmail/send.ts" export { verifyEmailAction } from "@/actions/verifyEmail/verify.ts" +export { forgotPasswordAction } from "@/actions/password/forgot.ts" +export { resetPasswordAction } from "@/actions/password/reset.ts" diff --git a/packages/core/src/actions/password/forgot.ts b/packages/core/src/actions/password/forgot.ts new file mode 100644 index 00000000..c74a6966 --- /dev/null +++ b/packages/core/src/actions/password/forgot.ts @@ -0,0 +1,31 @@ +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { forgotPassword } from "@/api/forgot-password.ts" +import { ForgotPasswordBodySchema, ForgotPasswordResponseSchema, RedirectOptionsSchema } from "@/shared/schemas/actions.ts" + +const forgotPasswordConfig = () => { + return createEndpointConfig({ + schemas: { + body: ForgotPasswordBodySchema, + searchParams: RedirectOptionsSchema, + response: ForgotPasswordResponseSchema, + }, + }) +} + +export const forgotPasswordAction = () => { + return createEndpoint( + "POST", + "/password/forgot", + async (ctx) => { + const { toResponse } = await forgotPassword(ctx.body.email, { + ctx: ctx.context, + request: ctx.request, + headers: ctx.request.headers, + redirect: ctx.searchParams.redirect, + redirectTo: ctx.searchParams.redirectTo, + }) + return toResponse() + }, + forgotPasswordConfig() + ) +} diff --git a/packages/core/src/actions/password/reset.ts b/packages/core/src/actions/password/reset.ts new file mode 100644 index 00000000..22a8f0b7 --- /dev/null +++ b/packages/core/src/actions/password/reset.ts @@ -0,0 +1,32 @@ +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { resetPassword } from "@/api/reset-password.ts" +import { RedirectOptionsSchema, ResetPasswordBodySchema, ResetPasswordResponseSchema } from "@/shared/schemas/actions.ts" + +const resetPasswordConfig = () => { + return createEndpointConfig({ + schemas: { + body: ResetPasswordBodySchema, + searchParams: RedirectOptionsSchema, + response: ResetPasswordResponseSchema, + }, + }) +} + +export const resetPasswordAction = () => { + return createEndpoint( + "POST", + "/password/reset", + async (ctx) => { + const { toResponse } = await resetPassword({ + ctx: ctx.context, + payload: ctx.body, + request: ctx.request, + headers: ctx.request.headers, + redirect: ctx.searchParams.redirect, + redirectTo: ctx.searchParams.redirectTo, + }) + return toResponse() + }, + resetPasswordConfig() + ) +} diff --git a/packages/core/src/api/create-api.ts b/packages/core/src/api/create-api.ts index 88d0797a..8057bd6c 100644 --- a/packages/core/src/api/create-api.ts +++ b/packages/core/src/api/create-api.ts @@ -13,6 +13,8 @@ import { isProviderConnected, sendVerificationEmail, verifyEmail, + forgotPassword, + resetPassword, } from "@/api/index.ts" import type { GlobalContext, InferSchema } from "@aura-stack/router" import type { @@ -49,6 +51,10 @@ import type { SendVerificationEmailAPIReturn, VerifyEmailAPIReturn, VerifyEmailAPIOptions, + ForgotPasswordAPIOptions, + ForgotPasswordAPIReturn, + ResetPasswordAPIReturn, + ResetPasswordAPIOptions, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SchemaTypes } from "@/identity/index.ts" @@ -309,6 +315,38 @@ export const createAuthAPI = => { return verifyEmail(token, { ctx, ...options }) }, + /** + * Initiates the forgot password flow on the server-side. It sends a password reset email to the specified + * email address. + * + * @param email - The email address to which the password reset email should be sent. + * @param options - Options for the API call, including headers and request object. + * @example + * const { success } = await api.forgotPassword("user@example.com", { + * headers: getHeaders() + * }) + */ + forgotPassword: async (email: string, options?: ForgotPasswordAPIOptions): Promise => { + return forgotPassword(email, { ctx, ...options }) + }, + /** + * Resets the password for a user on the server-side. It requires a valid password reset token and the new password. + * + * > **NOTE**: This is a complementary method to `forgotPassword` and should be used after the user + * has received the password reset email and provided a valid token. + * + * @param options - Options for the API call, including the reset token, new password, headers, and request object. + * @example + * const { success } = await api.resetPassword({ + * payload: { + * token: "reset-token-here", + * newPassword: "new-password-here" + * } + * }) + */ + resetPassword: async (options: ResetPasswordAPIOptions): Promise => { + return resetPassword({ ctx, ...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/forgot-password.ts b/packages/core/src/api/forgot-password.ts new file mode 100644 index 00000000..017e6e63 --- /dev/null +++ b/packages/core/src/api/forgot-password.ts @@ -0,0 +1,78 @@ +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 { ForgotPasswordAPIOptions, ForgotPasswordAPIReturn } from "@/@types/api.ts" + +export const forgotPassword = async ( + email: string, + { + ctx, + headers: headersInit, + request: requestInit, + redirectTo, + redirect: redirectInit = true, + }: FunctionAPIContext +): Promise => { + const { logger, sessionStrategy, passwordReset } = ctx + try { + logger?.log("FORGOT_PASSWORD_INITIATED") + const { request, rateLimit } = await createValidation( + ctx, + toStandardizedHeaders(headersInit ?? requestInit?.headers ?? {}) + ) + .buildRequest(requestInit, "/password/forgot") + .verifyRateLimit("forgotPassword") + .execute() + if (rateLimit) { + return rateLimit as ForgotPasswordAPIReturn + } + const { token, url } = await sessionStrategy.forgotPassword(email, request) + await passwordReset?.sendPasswordResetEmail?.({ email, token, url }) + + const builder = new HeadersBuilder(secureApiHeaders) + const { redirect, redirectURL } = await resolveApiRedirect(ctx, request, redirectInit, redirectTo, builder) + + return { + success: true, + redirect, + redirectURL: redirect ? null : redirectURL, + headers: builder.toHeaders(), + toResponse: () => { + return Response.json( + { + success: true, + redirect, + redirectURL: redirect ? null : redirectURL, + }, + { headers: builder.toHeaders(), status: redirect ? 302 : 200 } + ) + }, + } as ForgotPasswordAPIReturn + } catch (error) { + errorToLogMessage(error, "FORGOT_PASSWORD_ERROR", logger) + const { errors, statusCode } = handleApiError( + error, + "FORGOT_PASSWORD_ERROR", + "An error occurred while processing the forgot password request." + ) + + 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/index.ts b/packages/core/src/api/index.ts index 90925e3c..6f9bd00e 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -13,3 +13,5 @@ export { disconnectProvider } from "@/api/disconnect-provider.ts" export { isProviderConnected } from "@/api/is-provider-connected.ts" export { sendVerificationEmail } from "@/api/send-verification-email.ts" export { verifyEmail } from "@/api/verify-email.ts" +export { forgotPassword } from "@/api/forgot-password.ts" +export { resetPassword } from "@/api/reset-password.ts" diff --git a/packages/core/src/api/reset-password.ts b/packages/core/src/api/reset-password.ts new file mode 100644 index 00000000..61f81608 --- /dev/null +++ b/packages/core/src/api/reset-password.ts @@ -0,0 +1,76 @@ +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 { ResetPasswordAPIOptions, ResetPasswordAPIReturn } from "@/@types/api.ts" + +export const resetPassword = async ({ + ctx, + headers: headersInit, + request: requestInit, + payload, + redirectTo, + redirect: redirectInit = true, +}: FunctionAPIContext): Promise => { + const { logger, sessionStrategy } = ctx + try { + logger?.log("RESET_PASSWORD_INITIATED") + const { request, rateLimit } = await createValidation( + ctx, + toStandardizedHeaders(headersInit ?? requestInit?.headers ?? {}) + ) + .buildRequest(requestInit, "/password/reset") + .verifyRateLimit("resetPassword") + .execute() + if (rateLimit) { + return rateLimit as ResetPasswordAPIReturn + } + + await sessionStrategy.resetPassword(payload.token, payload.newPassword) + + const builder = new HeadersBuilder(secureApiHeaders) + const { redirect, redirectURL } = await resolveApiRedirect(ctx, request, redirectInit, redirectTo, builder) + + return { + success: true, + redirect, + redirectURL: redirect ? null : redirectURL, + headers: builder.toHeaders(), + toResponse: () => { + return Response.json( + { + success: true, + redirect, + redirectURL: redirect ? null : redirectURL, + }, + { headers: builder.toHeaders(), status: redirect ? 302 : 200 } + ) + }, + } as ResetPasswordAPIReturn + } catch (error) { + errorToLogMessage(error, "RESET_PASSWORD_ERROR", logger) + const { errors, statusCode } = handleApiError( + error, + "RESET_PASSWORD_ERROR", + "An error occurred while resetting the password." + ) + + 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/config/logger/catalog.ts b/packages/core/src/config/logger/catalog.ts index b993f33e..bf3c70e7 100644 --- a/packages/core/src/config/logger/catalog.ts +++ b/packages/core/src/config/logger/catalog.ts @@ -947,4 +947,40 @@ export const LOG_MESSAGES = { msgId: "VERIFY_EMAIL_ERROR", message: "Error occurred during email verification process for user", }, + FORGOT_PASSWORD_INITIATED: { + facility: 4, + severity: "debug", + msgId: "FORGOT_PASSWORD_INITIATED", + message: "Starting forgot password process for user", + }, + FORGOT_PASSWORD_ERROR: { + facility: 4, + severity: "error", + msgId: "FORGOT_PASSWORD_ERROR", + message: "Error occurred during forgot password process for user", + }, + RESET_PASSWORD_INITIATED: { + facility: 4, + severity: "debug", + msgId: "RESET_PASSWORD_INITIATED", + message: "Starting reset password process for user", + }, + RESET_PASSWORD_ERROR: { + facility: 4, + severity: "error", + msgId: "RESET_PASSWORD_ERROR", + message: "Error occurred during reset password process for user", + }, + CHANGE_PASSWORD_INITIATED: { + facility: 4, + severity: "debug", + msgId: "CHANGE_PASSWORD_INITIATED", + message: "Starting change password process for user", + }, + CHANGE_PASSWORD_ERROR: { + facility: 4, + severity: "error", + msgId: "CHANGE_PASSWORD_ERROR", + message: "Error occurred during change password process for user", + }, } as const diff --git a/packages/core/src/config/router.ts b/packages/core/src/config/router.ts index 2d511d75..2520b2fa 100644 --- a/packages/core/src/config/router.ts +++ b/packages/core/src/config/router.ts @@ -15,6 +15,8 @@ import { connectedAction, sendVerificationEmailAction, verifyEmailAction, + forgotPasswordAction, + resetPasswordAction, } from "@/actions/index.ts" import { createAuthAPI } from "@/api/index.ts" import { createContext } from "@/config/context.ts" @@ -67,6 +69,8 @@ export const createAuthInstance = < connectedAction(config.context.oauth), sendVerificationEmailAction(), verifyEmailAction(), + forgotPasswordAction(), + resetPasswordAction(), ], config ) diff --git a/packages/core/src/errors/catalog.ts b/packages/core/src/errors/catalog.ts index aee32371..73f79aee 100644 --- a/packages/core/src/errors/catalog.ts +++ b/packages/core/src/errors/catalog.ts @@ -884,6 +884,20 @@ export const ERROR_CATALOG = { "The requested user record could not be found in the database. The provided identifier does not match any existing user.", userMessage: "The user account could not be found. Please check your credentials and try again.", }, + INVALID_RESET_PASSWORD_TOKEN: { + type: "VALIDATION", + statusCode: 422, + name: "ValidationError", + message: "The provided password reset token is invalid or has expired.", + userMessage: "Invalid or expired password reset token.", + }, + NO_CREDENTIALS_ACCOUNT_FOUND: { + type: "AUTH_FLOW", + statusCode: 404, + name: "AuthError", + message: "No credential account was found for the user associated with the provided reset token.", + userMessage: "No credential account found for the user. Please contact support.", + }, } satisfies Record export type AuraErrorCode = keyof typeof ERROR_CATALOG diff --git a/packages/core/src/session/stateful/forgot-password.ts b/packages/core/src/session/stateful/forgot-password.ts new file mode 100644 index 00000000..3a6c55d6 --- /dev/null +++ b/packages/core/src/session/stateful/forgot-password.ts @@ -0,0 +1,25 @@ +import { createHash, createSecretValue } from "@/shared/crypto.ts" +import type { InternalStatefulContext } from "@/@types/internal.ts" + +export const forgotPassword = ({ ctx }: InternalStatefulContext) => { + const { sessionConfig, passwordReset, basePath } = ctx + return async (email: string, request: Request) => { + const user = await sessionConfig.adapter.getUserByEmail(email) + if (!user) { + return { token: null, url: null } + } + const token = createSecretValue() + const tokenHash = await createHash(token) + const expiresIn = passwordReset?.expiresIn ?? 1800 + await sessionConfig.adapter.createToken({ + userId: user.id, + expiresAt: new Date(Date.now() + expiresIn * 1000), + identifier: email, + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + const origin = new URL(request.url).origin + return { token, url: `${origin}${basePath}/password/reset?token=${token}` } + } +} diff --git a/packages/core/src/session/stateful/index.ts b/packages/core/src/session/stateful/index.ts index 7c34a725..39f76d3c 100644 --- a/packages/core/src/session/stateful/index.ts +++ b/packages/core/src/session/stateful/index.ts @@ -13,6 +13,8 @@ import { getProviderTokens } from "@/session/stateful/get-provider-tokens.ts" import { isProviderConnected } from "@/session/stateful/is-provider-connected.ts" import { sendVerificationEmail } from "@/session/stateful/send-verification-email.ts" import { verifyEmail } from "@/session/stateful/verify-email.ts" +import { forgotPassword } from "@/session/stateful/forgot-password.ts" +import { resetPassword } from "@/session/stateful/reset-password.ts" import type { SessionStrategy, User } from "@/@types/index.ts" import type { InternalStatefulContext } from "@/@types/internal.ts" @@ -24,7 +26,6 @@ export const createStatefulStrategy = ( signIn: signIn(ctx), getSession: getSession(ctx), revokeToken: revokeToken(ctx), - verifyEmail: verifyEmail(ctx), oauthCallback: oauthCallback(ctx), createSession: createSession(ctx), revokeSession: revokeSession(ctx), @@ -35,5 +36,8 @@ export const createStatefulStrategy = ( getProviderTokens: getProviderTokens(ctx), isProviderConnected: isProviderConnected(ctx), sendVerificationEmail: sendVerificationEmail(ctx), + verifyEmail: verifyEmail(ctx), + forgotPassword: forgotPassword(ctx), + resetPassword: resetPassword(ctx), } } diff --git a/packages/core/src/session/stateful/reset-password.ts b/packages/core/src/session/stateful/reset-password.ts new file mode 100644 index 00000000..831109da --- /dev/null +++ b/packages/core/src/session/stateful/reset-password.ts @@ -0,0 +1,35 @@ +import { AuraAuthError } from "@/errors/aura-error.ts" +import { createHash, hashPassword } from "@/shared/crypto.ts" +import type { InternalStatefulContext } from "@/@types/internal.ts" + +export const resetPassword = ({ ctx }: InternalStatefulContext) => { + const { sessionConfig } = ctx + return async (token: string, newPassword: string) => { + const tokenHash = await createHash(token) + const verificationToken = await sessionConfig.adapter.getTokenByHash(tokenHash) + + if ( + !verificationToken || + verificationToken.type !== "password_reset" || + verificationToken.expiresAt < new Date() || + verificationToken.revokedAt + ) { + throw new AuraAuthError({ code: "INVALID_RESET_PASSWORD_TOKEN" }) + } + + const accounts = await sessionConfig.adapter.getAccountsByUserId(verificationToken.userId) + const credentialAccount = accounts.find((acc) => acc.type === "credentials") + + if (!credentialAccount) { + throw new AuraAuthError({ code: "NO_CREDENTIALS_ACCOUNT_FOUND" }) + } + + /** + * @todo should support a custom password hashing function, similar to the signInCredentials API ? + */ + const passwordHash = await hashPassword(newPassword) + await sessionConfig.adapter.updatePasswordHash(credentialAccount.id, passwordHash) + + await sessionConfig.adapter.revokeToken(verificationToken.id) + } +} diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts index c6cb2218..6e5059c6 100644 --- a/packages/core/src/session/stateless/index.ts +++ b/packages/core/src/session/stateless/index.ts @@ -10,7 +10,13 @@ import { refreshUserInfo } from "@/session/stateless/refresh-userInfo.ts" import { signInCredentials } from "@/session/stateless/sign-in-credentials.ts" import { getProviderTokens } from "@/session/stateless/get-provider-tokens.ts" import { isProviderConnected } from "@/session/stateless/is-provider-connected.ts" -import { sendVerificationEmail, revokeSession, verifyEmail } from "@/session/stateless/unsupported.ts" +import { + sendVerificationEmail, + revokeSession, + verifyEmail, + forgotPassword, + resetPassword, +} from "@/session/stateless/unsupported.ts" import type { SessionStrategy, User } from "@/@types/index.ts" import type { InternalStatelessContext } from "@/@types/internal.ts" @@ -33,5 +39,7 @@ export const createStatelessStrategy = ( signInCredentials: signInCredentials(ctx), isProviderConnected: isProviderConnected(ctx), sendVerificationEmail: sendVerificationEmail(ctx), + forgotPassword: forgotPassword(ctx), + resetPassword: resetPassword(ctx), } } diff --git a/packages/core/src/session/stateless/unsupported.ts b/packages/core/src/session/stateless/unsupported.ts index e44c867b..8b09c083 100644 --- a/packages/core/src/session/stateless/unsupported.ts +++ b/packages/core/src/session/stateless/unsupported.ts @@ -18,3 +18,15 @@ export const verifyEmail = (_: InternalStatelessContext): SessionStrategy["verif throw new Error("verifyEmail is not supported in stateless mode.") } } + +export const forgotPassword = (_: InternalStatelessContext) => { + return async (_email: string, _request: Request): Promise<{ token: string; url: string }> => { + throw new Error("forgotPassword is not supported in stateless mode.") + } +} + +export const resetPassword = (_: InternalStatelessContext) => { + return async (_token: string, _newPassword: string): Promise => { + throw new Error("resetPassword 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 ed8bbf71..d0e703c4 100644 --- a/packages/core/src/shared/rate-limiter.ts +++ b/packages/core/src/shared/rate-limiter.ts @@ -96,6 +96,27 @@ export const createRateLimiterInstance = (config?: RateLimiterConfig, useProxyHe keyGenerator: (request) => getLimitKey(request, "verifyEmail"), ...config?.verifyEmail, } as RateLimiterRule, + forgotPassword: { + algorithm: "fixed-window", + limit: 5, + windowMs: 60 * 60 * 1000, + keyGenerator: (request) => getLimitKey(request, "forgotPassword"), + ...config?.forgotPassword, + } as RateLimiterRule, + resetPassword: { + algorithm: "fixed-window", + limit: 5, + windowMs: 60 * 60 * 1000, + keyGenerator: (request) => getLimitKey(request, "resetPassword"), + ...config?.resetPassword, + } as RateLimiterRule, + changePassword: { + algorithm: "fixed-window", + limit: 5, + windowMs: 60 * 60 * 1000, + keyGenerator: (request) => getLimitKey(request, "changePassword"), + ...config?.changePassword, + } as RateLimiterRule, }, }) } diff --git a/packages/core/src/shared/schemas/actions.ts b/packages/core/src/shared/schemas/actions.ts index 1a1e1b31..871cebdb 100644 --- a/packages/core/src/shared/schemas/actions.ts +++ b/packages/core/src/shared/schemas/actions.ts @@ -142,3 +142,24 @@ export const SendVerificationEmailActionResponseSchema = object({ export const SendVerificationEmailBodySchema = object({ email: email(), }) + +export const ForgotPasswordBodySchema = object({ + email: email(), +}) + +export const ForgotPasswordResponseSchema = object({ + success: boolean(), + redirect: boolean(), + redirectURL: string().nullable(), +}) + +export const ResetPasswordBodySchema = object({ + token: string().min(32), + newPassword: string().min(6).max(20), +}) + +export const ResetPasswordResponseSchema = object({ + success: boolean(), + redirect: boolean(), + redirectURL: string().nullable(), +}) From 016f0130045108d8be4838d4b247621145057f4c Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 24 Oct 2026 14:27:27 -0500 Subject: [PATCH 2/6] chore: apply suggested changes from code review --- packages/core/CHANGELOG.md | 2 +- packages/core/src/@types/config.ts | 4 ++-- packages/core/src/actions/password/reset.ts | 14 +++++++++++++- packages/core/src/config/context.ts | 1 + packages/core/src/shared/schemas/actions.ts | 2 +- 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index b7330f3b..557af549 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- Added the `forgotPassword()` and `resetPassword()` server API functions, along with the `POST /password/forgot` and `POST /password/reset` endpoints for client-side account recovery. These APIs support password recovery via email or SMS. [#292](https://github.com/aura-stack-ts/auth/pull/292) +- Added the `forgotPassword()` and `resetPassword()` server API functions, along with the `POST /password/forgot` and `POST /password/reset` endpoints for client-side account recovery. Password reset emails are sent through the `passwordReset.sendPasswordResetEmail` callback. [#293](https://github.com/aura-stack-ts/auth/pull/293) - Added the `sendVerificationEmail()` and `verifyEmail()` server API functions, along with the `POST /emailVerification/send` and `POST /emailVerification/verify` endpoints for client-side email verification. These APIs support email verification during sign-up and lazy verification after sign-in. [#291](https://github.com/aura-stack-ts/auth/pull/291) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 77ebbcff..9ce30337 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -211,12 +211,12 @@ export type AuthConfig< * @example * passwordReset: { * expiresIn: 3600, // 1 hour - * sendPasswordResetEmail: async ({ email, token, resetURL }) => { + * sendPasswordResetEmail: async ({ email, token, url }) => { * // Implement your email sending logic here * sendEmail({ * to: email, * subject: "Password Reset", - * html: `

Click here to reset your password.

`, + * html: `

Click here to reset your password.

`, * }) * } * } diff --git a/packages/core/src/actions/password/reset.ts b/packages/core/src/actions/password/reset.ts index 22a8f0b7..ad475ae3 100644 --- a/packages/core/src/actions/password/reset.ts +++ b/packages/core/src/actions/password/reset.ts @@ -9,12 +9,24 @@ const resetPasswordConfig = () => { searchParams: RedirectOptionsSchema, response: ResetPasswordResponseSchema, }, + hooks: { + onError: (ctx) => { + return ctx.json( + { + type: "VALIDATION", + code: "INVALID_VERIFICATION_TOKEN", + message: "Invalid or expired verification token", + }, + { status: 422 } + ) + }, + }, }) } export const resetPasswordAction = () => { return createEndpoint( - "POST", + "GET", "/password/reset", async (ctx) => { const { toResponse } = await resetPassword({ diff --git a/packages/core/src/config/context.ts b/packages/core/src/config/context.ts index e43bda7d..5597082a 100644 --- a/packages/core/src/config/context.ts +++ b/packages/core/src/config/context.ts @@ -43,6 +43,7 @@ export const createContext = Date: Sat, 24 Oct 2026 15:04:55 -0500 Subject: [PATCH 3/6] test(core): add forgotPassword server-side tests --- .../test/api/stateful/forgot-password.test.ts | 236 +++++++++++++ .../test/api/stateful/reset-password.test.ts | 315 ++++++++++++++++++ packages/core/test/setup/entities.ts | 18 + 3 files changed, 569 insertions(+) create mode 100644 packages/core/test/api/stateful/forgot-password.test.ts create mode 100644 packages/core/test/api/stateful/reset-password.test.ts diff --git a/packages/core/test/api/stateful/forgot-password.test.ts b/packages/core/test/api/stateful/forgot-password.test.ts new file mode 100644 index 00000000..71d01a92 --- /dev/null +++ b/packages/core/test/api/stateful/forgot-password.test.ts @@ -0,0 +1,236 @@ +import { describe, test, expect, vi } from "vitest" +import { createHash } from "@/shared/crypto.ts" +import { authInstance } from "@test/setup/auth.ts" +import { userEntity, verificationTokenEntity } from "@test/setup/entities.ts" + +describe("forgotPassword API", async () => { + test("success forgotPassword flow", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const output = await api.forgotPassword("john@example.com") + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) + + test("forgotPassword with non-existing email", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createTokenMock = vi.fn() + const sendPasswordResetEmailMock = vi.fn() + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const output = await api.forgotPassword("nonexistent@example.com") + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") + expect(createTokenMock).not.toHaveBeenCalled() + expect(sendPasswordResetEmailMock).toHaveBeenCalled() + }) + + test("forgotPassword with custom expiresIn", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn() + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + expiresIn: 3600, + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + const output = await api.forgotPassword("john@example.com") + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + }) + + test("success forgotPassword with redirectTo and redirect: true", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const output = await api.forgotPassword("john@example.com", { + redirect: true, + redirectTo: "/auth/password-reset", + }) + + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) + + test("success forgotPassword with redirectTo and redirect: false", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const output = await api.forgotPassword("john@example.com", { + redirect: false, + redirectTo: "/auth/password-reset", + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: "/auth/password-reset", + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) +}) diff --git a/packages/core/test/api/stateful/reset-password.test.ts b/packages/core/test/api/stateful/reset-password.test.ts new file mode 100644 index 00000000..e1bb4721 --- /dev/null +++ b/packages/core/test/api/stateful/reset-password.test.ts @@ -0,0 +1,315 @@ +import { describe, test, expect, vi } from "vitest" +import { createHash } from "@/shared/crypto.ts" +import { authInstance } from "@test/setup/auth.ts" +import { resetPasswordAccountEntity, resetPasswordTokenEntity } from "@test/setup/entities.ts" + +describe("resetPassword API", async () => { + test("success resetPassword flow", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + }) + + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) + + test("resetPassword with no-existing token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(null) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "INVALID_RESET_PASSWORD_TOKEN", + message: "Invalid or expired password reset token.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with no-credentials account", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "NO_CREDENTIALS_ACCOUNT_FOUND", + message: "No credential account found for the user. Please contact support.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with expired token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const expiredTokenEntity = { + ...resetPasswordTokenEntity, + expiresAt: new Date(Date.now() - 1000), + } + + const getTokenByHashMock = vi.fn().mockReturnValue(expiredTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "INVALID_RESET_PASSWORD_TOKEN", + message: "Invalid or expired password reset token.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with revoked token", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const revokedTokenEntity = { + ...resetPasswordTokenEntity, + revokedAt: new Date(), + } + + const getTokenByHashMock = vi.fn().mockReturnValue(revokedTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + }) + + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "INVALID_RESET_PASSWORD_TOKEN", + message: "Invalid or expired password reset token.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("success resetPassword flow with redirectTo and redirect: true", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + redirect: true, + redirectTo: "https://example.com/success", + }) + + expect(output).toEqual({ + success: true, + redirect: true, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) + + test("success resetPassword flow with redirectTo and redirect: false", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { api } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const output = await api.resetPassword({ + payload: { + token, + newPassword: "new-password", + }, + 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(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) +}) diff --git a/packages/core/test/setup/entities.ts b/packages/core/test/setup/entities.ts index a0edf5c6..a7ff7f74 100644 --- a/packages/core/test/setup/entities.ts +++ b/packages/core/test/setup/entities.ts @@ -105,3 +105,21 @@ export const verificationTokenEntity: VerificationTokenEntity = { revokedAt: null, createdAt: new Date(), } + +export const resetPasswordTokenEntity: VerificationTokenEntity = { + ...verificationTokenEntity, + type: "password_reset", + expiresAt: new Date(Date.now() + 30 * 60 * 1000), +} + +export const resetPasswordAccountEntity: AccountEntity = { + id: "account-123", + userId: "user-123", + provider: "credentials", + providerUserId: "user-123", + status: "active", + type: "credentials", + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, +} From b0c521b557444ec925cdbaad94a16579fc9c4b4f Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sat, 24 Oct 2026 20:37:17 -0500 Subject: [PATCH 4/6] test(core): add forgotPassword endpoint tests --- packages/core/src/actions/password/reset.ts | 6 +- packages/core/src/shared/schemas/actions.ts | 2 +- .../core/test/actions/password/forgot.test.ts | 255 ++++++++++++++ .../core/test/actions/password/reset.test.ts | 310 ++++++++++++++++++ 4 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/actions/password/forgot.test.ts create mode 100644 packages/core/test/actions/password/reset.test.ts diff --git a/packages/core/src/actions/password/reset.ts b/packages/core/src/actions/password/reset.ts index ad475ae3..c7811bf2 100644 --- a/packages/core/src/actions/password/reset.ts +++ b/packages/core/src/actions/password/reset.ts @@ -14,8 +14,8 @@ const resetPasswordConfig = () => { return ctx.json( { type: "VALIDATION", - code: "INVALID_VERIFICATION_TOKEN", - message: "Invalid or expired verification token", + code: "INVALID_RESET_PASSWORD_TOKEN", + message: "Invalid or expired reset password token", }, { status: 422 } ) @@ -26,7 +26,7 @@ const resetPasswordConfig = () => { export const resetPasswordAction = () => { return createEndpoint( - "GET", + "POST", "/password/reset", async (ctx) => { const { toResponse } = await resetPassword({ diff --git a/packages/core/src/shared/schemas/actions.ts b/packages/core/src/shared/schemas/actions.ts index 07c157b5..7b1b2032 100644 --- a/packages/core/src/shared/schemas/actions.ts +++ b/packages/core/src/shared/schemas/actions.ts @@ -155,7 +155,7 @@ export const ForgotPasswordResponseSchema = object({ export const ResetPasswordBodySchema = object({ token: string().min(32), - newPassword: string(), + newPassword: string().min(1), }) export const ResetPasswordResponseSchema = object({ diff --git a/packages/core/test/actions/password/forgot.test.ts b/packages/core/test/actions/password/forgot.test.ts new file mode 100644 index 00000000..9c2687ab --- /dev/null +++ b/packages/core/test/actions/password/forgot.test.ts @@ -0,0 +1,255 @@ +import { describe, test, expect, vi } from "vitest" +import { createHash } from "@/shared/crypto.ts" +import { authInstance } from "@test/setup/auth.ts" +import { userEntity, verificationTokenEntity } from "@test/setup/entities.ts" + +describe("forgotPasswordAction", async () => { + test("success forgotPassword flow", async () => { + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const response = await POST( + new Request("https://example.com/auth/password/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "john@example.com" }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) + + test("forgotPassword with non-existing email", async () => { + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createTokenMock = vi.fn() + const sendPasswordResetEmailMock = vi.fn() + + const { + handlers: { POST }, + } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const response = await POST( + new Request("https://example.com/auth/password/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "nonexistent@example.com" }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") + expect(createTokenMock).not.toHaveBeenCalled() + expect(sendPasswordResetEmailMock).toHaveBeenCalled() + }) + + test("forgotPassword with custom expiresIn", async () => { + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn() + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + expiresIn: 3600, + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + const response = await POST( + new Request("https://example.com/auth/password/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "john@example.com" }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + }) + + test("success forgotPassword with redirectTo and redirect: true", async () => { + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const response = await POST( + new Request("https://example.com/auth/password/forgot?redirectTo=/auth/password-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "john@example.com" }), + }) + ) + + expect(response.status).toBe(302) + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) + + test("success forgotPassword with redirectTo and redirect: false", async () => { + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createTokenMock = vi.fn().mockReturnValue(verificationTokenEntity) + const sendPasswordResetEmailMock = vi.fn() + + const token = "mocked-token" + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createToken: createTokenMock, + }, + { + passwordReset: { + sendPasswordResetEmail: sendPasswordResetEmailMock, + }, + } + ) + + const response = await POST( + new Request("https://example.com/auth/password/forgot?redirectTo=/auth/password-reset&redirect=false", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "john@example.com" }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/auth/password-reset", + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createTokenMock).toHaveBeenCalledWith({ + userId: "user-123", + expiresAt: expect.any(Date), + identifier: "john@example.com", + newIdentifier: null, + type: "password_reset", + tokenHash, + }) + expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ + email: "john@example.com", + token, + url: `https://example.com/auth/password/reset?token=${token}`, + }) + }) +}) diff --git a/packages/core/test/actions/password/reset.test.ts b/packages/core/test/actions/password/reset.test.ts new file mode 100644 index 00000000..14e68c89 --- /dev/null +++ b/packages/core/test/actions/password/reset.test.ts @@ -0,0 +1,310 @@ +import { describe, test, expect, vi } from "vitest" +import { createHash, createSecretValue } from "@/shared/crypto.ts" +import { authInstance } from "@test/setup/auth.ts" +import { resetPasswordAccountEntity, resetPasswordTokenEntity } from "@test/setup/entities.ts" + +describe("resetPasswordAction", async () => { + test("success resetPassword flow", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + newPassword: "new-password", + }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) + + test("resetPassword with no-existing token", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(null) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + newPassword: "new-password", + }), + }) + ) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with no-credentials account", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + newPassword: "new-password", + }), + }) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with expired token", async () => { + const expiredTokenEntity = { + ...resetPasswordTokenEntity, + expiresAt: new Date(Date.now() - 1000), + } + + const getTokenByHashMock = vi.fn().mockReturnValue(expiredTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + newPassword: "new-password", + }), + }) + ) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("resetPassword with revoked token", async () => { + const revokedTokenEntity = { + ...resetPasswordTokenEntity, + revokedAt: new Date(), + } + + const getTokenByHashMock = vi.fn().mockReturnValue(revokedTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token, + newPassword: "new-password", + }), + }) + ) + + expect(response.status).toBe(422) + expect(await response.json()).toEqual({ + success: false, + redirect: false, + redirectURL: null, + }) + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).not.toHaveBeenCalled() + expect(updatePasswordHashMock).not.toHaveBeenCalled() + expect(revokeTokenMock).not.toHaveBeenCalled() + }) + + test("success resetPassword flow with redirectTo and redirect: true", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset?redirectTo=/success", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, newPassword: "new-password" }), + }) + ) + + expect(response.status).toBe(302) + expect(await response.json()).toEqual({ + success: true, + redirect: true, + redirectURL: null, + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) + + test("success resetPassword flow with redirectTo and redirect: false", async () => { + const getTokenByHashMock = vi.fn().mockReturnValue(resetPasswordTokenEntity) + const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) + const updatePasswordHashMock = vi.fn() + const revokeTokenMock = vi.fn() + + const token = createSecretValue() + const tokenHash = await createHash(token) + vi.spyOn(await import("@/shared/crypto.ts"), "createSecretValue").mockReturnValue(token) + + const { + handlers: { POST }, + } = authInstance({ + getTokenByHash: getTokenByHashMock, + getAccountsByUserId: getAccountsByUserIdMock, + updatePasswordHash: updatePasswordHashMock, + revokeToken: revokeTokenMock, + }) + + const response = await POST( + new Request("https://example.com/auth/password/reset?redirectTo=/success&redirect=false", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, newPassword: "new-password" }), + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + redirect: false, + redirectURL: "/success", + }) + + expect(getTokenByHashMock).toHaveBeenCalledWith(tokenHash) + expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") + expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) + expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + }) +}) From 12a0b942f96178eca3799c3fb1a0f3778cce3a98 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Tue, 3 Nov 2026 19:24:28 -0500 Subject: [PATCH 5/6] chore(core): clean up forgot password and reset password APIs --- packages/core/src/@types/session.ts | 9 +++------ packages/core/src/actions/verifyEmail/send.ts | 4 ++-- packages/core/src/api/forgot-password.ts | 4 ++-- packages/core/src/api/reset-password.ts | 2 +- packages/core/src/session/stateful/forgot-password.ts | 9 +++++---- packages/core/src/session/stateful/reset-password.ts | 5 +++-- packages/core/src/session/stateless/unsupported.ts | 8 ++++---- packages/core/test/actions/password/forgot.test.ts | 6 +++--- packages/core/test/api/stateful/forgot-password.test.ts | 8 ++++++-- 9 files changed, 29 insertions(+), 26 deletions(-) diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index bb00567a..9b9a04ae 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -356,12 +356,9 @@ export interface SessionStrategy { verifyEmail(options: { token: string }): Promise - forgotPassword( - email: string, - request: Request - ): Promise<{ - token: string | null - url: string | null + forgotPassword(options: { email: string; origin: string }): Promise<{ + token: string + url: string }> resetPassword(options: { token: string; newPassword: string }): Promise diff --git a/packages/core/src/actions/verifyEmail/send.ts b/packages/core/src/actions/verifyEmail/send.ts index 4df7d64c..f4535693 100644 --- a/packages/core/src/actions/verifyEmail/send.ts +++ b/packages/core/src/actions/verifyEmail/send.ts @@ -2,15 +2,15 @@ import { createEndpoint, createEndpointConfig } from "@aura-stack/router" import { sendVerificationEmail } from "@/api/send-verification-email.ts" import { RedirectOptionsSchema, - SendVerificationEmailActionResponseSchema, SendVerificationEmailBodySchema, + SendVerificationEmailActionResponseSchema, } from "@/shared/schemas/actions.ts" const sendVerificationEmailConfig = () => { return createEndpointConfig({ schemas: { - searchParams: RedirectOptionsSchema, body: SendVerificationEmailBodySchema, + searchParams: RedirectOptionsSchema, response: SendVerificationEmailActionResponseSchema, }, }) diff --git a/packages/core/src/api/forgot-password.ts b/packages/core/src/api/forgot-password.ts index 017e6e63..dfbb7a1f 100644 --- a/packages/core/src/api/forgot-password.ts +++ b/packages/core/src/api/forgot-password.ts @@ -17,7 +17,7 @@ export const forgotPassword = async ( const { logger, sessionStrategy, passwordReset } = ctx try { logger?.log("FORGOT_PASSWORD_INITIATED") - const { request, rateLimit } = await createValidation( + const { request, rateLimit, originURL } = await createValidation( ctx, toStandardizedHeaders(headersInit ?? requestInit?.headers ?? {}) ) @@ -27,7 +27,7 @@ export const forgotPassword = async ( if (rateLimit) { return rateLimit as ForgotPasswordAPIReturn } - const { token, url } = await sessionStrategy.forgotPassword(email, request) + const { token, url } = await sessionStrategy.forgotPassword({ email, origin: originURL }) await passwordReset?.sendPasswordResetEmail?.({ email, token, url }) const builder = new HeadersBuilder(secureApiHeaders) diff --git a/packages/core/src/api/reset-password.ts b/packages/core/src/api/reset-password.ts index 61f81608..7f1e4f28 100644 --- a/packages/core/src/api/reset-password.ts +++ b/packages/core/src/api/reset-password.ts @@ -26,7 +26,7 @@ export const resetPassword = async ({ return rateLimit as ResetPasswordAPIReturn } - await sessionStrategy.resetPassword(payload.token, payload.newPassword) + await sessionStrategy.resetPassword(payload) const builder = new HeadersBuilder(secureApiHeaders) const { redirect, redirectURL } = await resolveApiRedirect(ctx, request, redirectInit, redirectTo, builder) diff --git a/packages/core/src/session/stateful/forgot-password.ts b/packages/core/src/session/stateful/forgot-password.ts index 3a6c55d6..16369552 100644 --- a/packages/core/src/session/stateful/forgot-password.ts +++ b/packages/core/src/session/stateful/forgot-password.ts @@ -1,12 +1,14 @@ +import { AuraAuthError } from "@/errors/aura-error.ts" import { createHash, createSecretValue } from "@/shared/crypto.ts" +import type { SessionStrategy } from "@/@types/session.ts" import type { InternalStatefulContext } from "@/@types/internal.ts" -export const forgotPassword = ({ ctx }: InternalStatefulContext) => { +export const forgotPassword = ({ ctx }: InternalStatefulContext): SessionStrategy["forgotPassword"] => { const { sessionConfig, passwordReset, basePath } = ctx - return async (email: string, request: Request) => { + return async ({ email, origin }) => { const user = await sessionConfig.adapter.getUserByEmail(email) if (!user) { - return { token: null, url: null } + throw new AuraAuthError({ code: "USER_NOT_FOUND" }) } const token = createSecretValue() const tokenHash = await createHash(token) @@ -19,7 +21,6 @@ export const forgotPassword = ({ ctx }: InternalStatefulContext) => { type: "password_reset", tokenHash, }) - const origin = new URL(request.url).origin return { token, url: `${origin}${basePath}/password/reset?token=${token}` } } } diff --git a/packages/core/src/session/stateful/reset-password.ts b/packages/core/src/session/stateful/reset-password.ts index 831109da..3efe1ebf 100644 --- a/packages/core/src/session/stateful/reset-password.ts +++ b/packages/core/src/session/stateful/reset-password.ts @@ -1,10 +1,11 @@ import { AuraAuthError } from "@/errors/aura-error.ts" import { createHash, hashPassword } from "@/shared/crypto.ts" +import type { SessionStrategy } from "@/@types/session.ts" import type { InternalStatefulContext } from "@/@types/internal.ts" -export const resetPassword = ({ ctx }: InternalStatefulContext) => { +export const resetPassword = ({ ctx }: InternalStatefulContext): SessionStrategy["resetPassword"] => { const { sessionConfig } = ctx - return async (token: string, newPassword: string) => { + return async ({ token, newPassword }) => { const tokenHash = await createHash(token) const verificationToken = await sessionConfig.adapter.getTokenByHash(tokenHash) diff --git a/packages/core/src/session/stateless/unsupported.ts b/packages/core/src/session/stateless/unsupported.ts index 8b09c083..5ff1fe90 100644 --- a/packages/core/src/session/stateless/unsupported.ts +++ b/packages/core/src/session/stateless/unsupported.ts @@ -19,14 +19,14 @@ export const verifyEmail = (_: InternalStatelessContext): SessionStrategy["verif } } -export const forgotPassword = (_: InternalStatelessContext) => { - return async (_email: string, _request: Request): Promise<{ token: string; url: string }> => { +export const forgotPassword = (_: InternalStatelessContext): SessionStrategy["forgotPassword"] => { + return async () => { throw new Error("forgotPassword is not supported in stateless mode.") } } -export const resetPassword = (_: InternalStatelessContext) => { - return async (_token: string, _newPassword: string): Promise => { +export const resetPassword = (_: InternalStatelessContext): SessionStrategy["resetPassword"] => { + return async () => { throw new Error("resetPassword is not supported in stateless mode.") } } diff --git a/packages/core/test/actions/password/forgot.test.ts b/packages/core/test/actions/password/forgot.test.ts index 9c2687ab..bee7bf24 100644 --- a/packages/core/test/actions/password/forgot.test.ts +++ b/packages/core/test/actions/password/forgot.test.ts @@ -85,16 +85,16 @@ describe("forgotPasswordAction", async () => { }) ) - expect(response.status).toBe(200) + expect(response.status).toBe(404) expect(await response.json()).toEqual({ - success: true, + success: false, redirect: false, redirectURL: null, }) expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") expect(createTokenMock).not.toHaveBeenCalled() - expect(sendPasswordResetEmailMock).toHaveBeenCalled() + expect(sendPasswordResetEmailMock).not.toHaveBeenCalled() }) test("forgotPassword with custom expiresIn", async () => { diff --git a/packages/core/test/api/stateful/forgot-password.test.ts b/packages/core/test/api/stateful/forgot-password.test.ts index 71d01a92..1a485d21 100644 --- a/packages/core/test/api/stateful/forgot-password.test.ts +++ b/packages/core/test/api/stateful/forgot-password.test.ts @@ -75,7 +75,11 @@ describe("forgotPassword API", async () => { const output = await api.forgotPassword("nonexistent@example.com") expect(output).toEqual({ - success: true, + success: false, + error: { + code: "USER_NOT_FOUND", + message: "The user account could not be found. Please check your credentials and try again.", + }, redirect: false, redirectURL: null, headers: expect.any(Headers), @@ -84,7 +88,7 @@ describe("forgotPassword API", async () => { expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") expect(createTokenMock).not.toHaveBeenCalled() - expect(sendPasswordResetEmailMock).toHaveBeenCalled() + expect(sendPasswordResetEmailMock).not.toHaveBeenCalled() }) test("forgotPassword with custom expiresIn", async () => { From 13ef31038da7e452da2ab41a47c9d75025b78464 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 4 Nov 2026 11:22:47 -0500 Subject: [PATCH 6/6] chore(core): apply suggested changes from code review --- packages/core/src/@types/config.ts | 19 +++++++++++++++++ packages/core/src/config/context.ts | 3 ++- .../core/src/config/get-password-reset.ts | 13 ++++++++++++ packages/core/src/errors/catalog.ts | 8 +++++++ .../src/session/stateful/forgot-password.ts | 4 ++-- .../src/session/stateful/reset-password.ts | 2 +- .../core/test/actions/password/forgot.test.ts | 8 +++---- .../core/test/actions/password/reset.test.ts | 21 +++++++++++++++++++ .../test/api/stateful/forgot-password.test.ts | 8 +++---- .../test/api/stateful/reset-password.test.ts | 21 +++++++++++++++++++ .../core/test/config/password-reset.test.ts | 16 ++++++++++++++ 11 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/config/get-password-reset.ts create mode 100644 packages/core/test/config/password-reset.test.ts diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 9ce30337..d07c6b07 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -21,6 +21,7 @@ import type { ZodObject } from "zod" import type { SerializeOptions } from "@aura-stack/router/cookie" import type { CredentialsConfigContext, OnCreateUserContext } from "@/@types/internal.ts" import type { RateLimiterRule, RateLimiterConfig as RaterLimiterBaseConfig } from "@aura-stack/rate-limiter/types" +import type { RoutePattern } from "@aura-stack/router" /** * Main configuration interface for Aura Auth. @@ -530,14 +531,32 @@ export interface EmailVerificationConfig { } export interface PasswordResetConfig { + /** + * Defines the path for the password reset endpoint. By default, it is set to `/auth/password-reset`. + * You can customize this path to match your application's routing structure. + * @example + * path: "/auth/reset-password" + */ + path?: RoutePattern /** * Defines the expiration time for the password reset token in seconds. By default, it is * set to 30 minutes (1800 seconds). + * @example + * expiresIn: 3600 // 1 hour */ expiresIn?: number /** * Callback function to send the password reset email. It receives the email address, token, and reset URL. * You must implement this function to send the email using your preferred email service. + * @example + * sendPasswordResetEmail: async ({ email, token, url }) => { + * // Send the password reset email to the user with the provided token and URL + * sendEmail({ + * to: email, + * subject: "Reset your password", + * body: `Click the following link to reset your password: ${url}`, + * }) + * } */ sendPasswordResetEmail?: (options: { email: string; token: string | null; url: string | null }) => Awaitable } diff --git a/packages/core/src/config/context.ts b/packages/core/src/config/context.ts index 5597082a..5f4bb841 100644 --- a/packages/core/src/config/context.ts +++ b/packages/core/src/config/context.ts @@ -11,6 +11,7 @@ import { createJoseManager } from "@/session/jose-manager.ts" import { getOAuthProvidersConfig } from "@/config/get-oauth.ts" import { createProxyLogger } from "@/config/logger/create-logger.ts" import { createRateLimiterInstance } from "@/shared/rate-limiter.ts" +import { getPasswordResetConfig } from "@/config/get-password-reset.ts" import { getTrustedOriginsConfig } from "@/config/get-trusted-origins.ts" import { getEmailVerificationConfig } from "@/config/get-email-verification.ts" import { getTrustedProxyHeadersConfig } from "@/config/get-trusted-proxy-headers.ts" @@ -43,7 +44,7 @@ export const createContext = ( + config: AuthConfig | undefined +) => { + const expiresIn = config?.passwordReset?.expiresIn + if (expiresIn !== undefined && (!Number.isInteger(expiresIn) || expiresIn <= 0)) { + throw new AuraAuthError({ code: "INVALID_PASSWORD_RESET_EXPIRES_IN_VALUE" }) + } + return config?.passwordReset +} diff --git a/packages/core/src/errors/catalog.ts b/packages/core/src/errors/catalog.ts index 73f79aee..d70ecf7a 100644 --- a/packages/core/src/errors/catalog.ts +++ b/packages/core/src/errors/catalog.ts @@ -898,6 +898,14 @@ export const ERROR_CATALOG = { message: "No credential account was found for the user associated with the provided reset token.", userMessage: "No credential account found for the user. Please contact support.", }, + INVALID_PASSWORD_RESET_EXPIRES_IN_VALUE: { + type: "VALIDATION", + statusCode: 500, + name: "ConfigError", + message: + "The 'expiresIn' configuration value for password reset must be a positive integer representing the number of seconds until expiration. The provided value does not meet this requirement.", + userMessage: "Internal library configuration error. The password reset expiration time must be a positive integer.", + }, } satisfies Record export type AuraErrorCode = keyof typeof ERROR_CATALOG diff --git a/packages/core/src/session/stateful/forgot-password.ts b/packages/core/src/session/stateful/forgot-password.ts index 16369552..a2fb0b03 100644 --- a/packages/core/src/session/stateful/forgot-password.ts +++ b/packages/core/src/session/stateful/forgot-password.ts @@ -8,7 +8,7 @@ export const forgotPassword = ({ ctx }: InternalStatefulContext): SessionStrateg return async ({ email, origin }) => { const user = await sessionConfig.adapter.getUserByEmail(email) if (!user) { - throw new AuraAuthError({ code: "USER_NOT_FOUND" }) + throw new AuraAuthError({ code: "SILENT_USER_NOT_FOUND" }) } const token = createSecretValue() const tokenHash = await createHash(token) @@ -21,6 +21,6 @@ export const forgotPassword = ({ ctx }: InternalStatefulContext): SessionStrateg type: "password_reset", tokenHash, }) - return { token, url: `${origin}${basePath}/password/reset?token=${token}` } + return { token, url: `${origin}${basePath}${passwordReset?.path || "/password-reset"}?token=${token}` } } } diff --git a/packages/core/src/session/stateful/reset-password.ts b/packages/core/src/session/stateful/reset-password.ts index 3efe1ebf..eb2597dd 100644 --- a/packages/core/src/session/stateful/reset-password.ts +++ b/packages/core/src/session/stateful/reset-password.ts @@ -30,7 +30,7 @@ export const resetPassword = ({ ctx }: InternalStatefulContext): SessionStrategy */ const passwordHash = await hashPassword(newPassword) await sessionConfig.adapter.updatePasswordHash(credentialAccount.id, passwordHash) - await sessionConfig.adapter.revokeToken(verificationToken.id) + await sessionConfig.adapter.revokeAllSessions(verificationToken.userId, "password_changed") } } diff --git a/packages/core/test/actions/password/forgot.test.ts b/packages/core/test/actions/password/forgot.test.ts index bee7bf24..21394338 100644 --- a/packages/core/test/actions/password/forgot.test.ts +++ b/packages/core/test/actions/password/forgot.test.ts @@ -54,7 +54,7 @@ describe("forgotPasswordAction", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) @@ -85,7 +85,7 @@ describe("forgotPasswordAction", async () => { }) ) - expect(response.status).toBe(404) + expect(response.status).toBe(200) expect(await response.json()).toEqual({ success: false, redirect: false, @@ -195,7 +195,7 @@ describe("forgotPasswordAction", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) @@ -249,7 +249,7 @@ describe("forgotPasswordAction", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) }) diff --git a/packages/core/test/actions/password/reset.test.ts b/packages/core/test/actions/password/reset.test.ts index 14e68c89..c5e367a8 100644 --- a/packages/core/test/actions/password/reset.test.ts +++ b/packages/core/test/actions/password/reset.test.ts @@ -9,6 +9,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -21,6 +22,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -45,6 +47,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) test("resetPassword with no-existing token", async () => { @@ -52,6 +55,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -64,6 +68,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -88,6 +93,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with no-credentials account", async () => { @@ -95,6 +101,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -107,6 +114,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -131,6 +139,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with expired token", async () => { @@ -143,6 +152,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -155,6 +165,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -179,6 +190,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with revoked token", async () => { @@ -191,6 +203,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -203,6 +216,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -226,6 +240,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("success resetPassword flow with redirectTo and redirect: true", async () => { @@ -233,6 +248,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -245,6 +261,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -266,6 +283,7 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) test("success resetPassword flow with redirectTo and redirect: false", async () => { @@ -273,6 +291,7 @@ describe("resetPasswordAction", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = createSecretValue() const tokenHash = await createHash(token) @@ -285,6 +304,7 @@ describe("resetPasswordAction", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const response = await POST( @@ -306,5 +326,6 @@ describe("resetPasswordAction", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) }) diff --git a/packages/core/test/api/stateful/forgot-password.test.ts b/packages/core/test/api/stateful/forgot-password.test.ts index 1a485d21..2255a2ed 100644 --- a/packages/core/test/api/stateful/forgot-password.test.ts +++ b/packages/core/test/api/stateful/forgot-password.test.ts @@ -49,7 +49,7 @@ describe("forgotPassword API", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) @@ -77,7 +77,7 @@ describe("forgotPassword API", async () => { expect(output).toEqual({ success: false, error: { - code: "USER_NOT_FOUND", + code: "SILENT_USER_NOT_FOUND", message: "The user account could not be found. Please check your credentials and try again.", }, redirect: false, @@ -182,7 +182,7 @@ describe("forgotPassword API", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) @@ -234,7 +234,7 @@ describe("forgotPassword API", async () => { expect(sendPasswordResetEmailMock).toHaveBeenCalledWith({ email: "john@example.com", token, - url: `https://example.com/auth/password/reset?token=${token}`, + url: `https://example.com/auth/password-reset?token=${token}`, }) }) }) diff --git a/packages/core/test/api/stateful/reset-password.test.ts b/packages/core/test/api/stateful/reset-password.test.ts index e1bb4721..6bfa69ab 100644 --- a/packages/core/test/api/stateful/reset-password.test.ts +++ b/packages/core/test/api/stateful/reset-password.test.ts @@ -11,6 +11,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -21,6 +22,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -42,6 +44,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) test("resetPassword with no-existing token", async () => { @@ -51,6 +54,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -61,6 +65,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -86,6 +91,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with no-credentials account", async () => { @@ -95,6 +101,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -105,6 +112,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -130,6 +138,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with expired token", async () => { @@ -144,6 +153,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -154,6 +164,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -179,6 +190,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("resetPassword with revoked token", async () => { @@ -193,6 +205,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -203,6 +216,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -227,6 +241,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).not.toHaveBeenCalled() expect(updatePasswordHashMock).not.toHaveBeenCalled() expect(revokeTokenMock).not.toHaveBeenCalled() + expect(revokeAllSessionsMock).not.toHaveBeenCalled() }) test("success resetPassword flow with redirectTo and redirect: true", async () => { @@ -236,6 +251,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -246,6 +262,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -269,6 +286,7 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) test("success resetPassword flow with redirectTo and redirect: false", async () => { @@ -278,6 +296,7 @@ describe("resetPassword API", async () => { const getAccountsByUserIdMock = vi.fn().mockReturnValue([resetPasswordAccountEntity]) const updatePasswordHashMock = vi.fn() const revokeTokenMock = vi.fn() + const revokeAllSessionsMock = vi.fn() const token = "mocked-token" const tokenHash = await createHash(token) @@ -288,6 +307,7 @@ describe("resetPassword API", async () => { getAccountsByUserId: getAccountsByUserIdMock, updatePasswordHash: updatePasswordHashMock, revokeToken: revokeTokenMock, + revokeAllSessions: revokeAllSessionsMock, }) const output = await api.resetPassword({ @@ -311,5 +331,6 @@ describe("resetPassword API", async () => { expect(getAccountsByUserIdMock).toHaveBeenCalledWith("user-123") expect(updatePasswordHashMock).toHaveBeenCalledWith("account-123", expect.any(String)) expect(revokeTokenMock).toHaveBeenCalledWith("token-123") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") }) }) diff --git a/packages/core/test/config/password-reset.test.ts b/packages/core/test/config/password-reset.test.ts new file mode 100644 index 00000000..850e812c --- /dev/null +++ b/packages/core/test/config/password-reset.test.ts @@ -0,0 +1,16 @@ +import { getPasswordResetConfig } from "@/config/get-password-reset.ts" +import { describe, test, expect } from "vitest" + +describe("getPasswordResetConfig", () => { + test("should throw an error if expiresIn is not a positive integer", () => { + expect(() => getPasswordResetConfig({ passwordReset: { expiresIn: -1000 } })).toThrow( + "The 'expiresIn' configuration value for password reset must be a positive integer representing the number of seconds until expiration. The provided value does not meet this requirement." + ) + }) + + test("should throw an error if expiresIn is not a number", () => { + expect(() => getPasswordResetConfig({ passwordReset: { expiresIn: Infinity } })).toThrow( + "The 'expiresIn' configuration value for password reset must be a positive integer representing the number of seconds until expiration. The provided value does not meet this requirement." + ) + }) +})