diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 8186c00b..557af549 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. 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) - 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..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. @@ -206,6 +207,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, url }) => { + * // 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 +530,37 @@ export interface EmailVerificationConfig { sendVerificationEmail?: (options: { email: string; token: string | null; url: string | null }) => Awaitable } +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 +} + // #region Rate Limiter export type RateLimiterConfig = Partial< @@ -530,7 +578,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..9b9a04ae 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -355,6 +355,13 @@ export interface SessionStrategy { }> verifyEmail(options: { token: string }): Promise + + forgotPassword(options: { email: string; origin: string }): Promise<{ + token: string + url: string + }> + + 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..c7811bf2 --- /dev/null +++ b/packages/core/src/actions/password/reset.ts @@ -0,0 +1,44 @@ +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, + }, + hooks: { + onError: (ctx) => { + return ctx.json( + { + type: "VALIDATION", + code: "INVALID_RESET_PASSWORD_TOKEN", + message: "Invalid or expired reset password token", + }, + { status: 422 } + ) + }, + }, + }) +} + +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/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/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..dfbb7a1f --- /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, originURL } = 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, origin: originURL }) + 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..7f1e4f28 --- /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) + + 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/context.ts b/packages/core/src/config/context.ts index e43bda7d..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,6 +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/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..d70ecf7a 100644 --- a/packages/core/src/errors/catalog.ts +++ b/packages/core/src/errors/catalog.ts @@ -884,6 +884,28 @@ 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.", + }, + 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 new file mode 100644 index 00000000..a2fb0b03 --- /dev/null +++ b/packages/core/src/session/stateful/forgot-password.ts @@ -0,0 +1,26 @@ +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): SessionStrategy["forgotPassword"] => { + const { sessionConfig, passwordReset, basePath } = ctx + return async ({ email, origin }) => { + const user = await sessionConfig.adapter.getUserByEmail(email) + if (!user) { + throw new AuraAuthError({ code: "SILENT_USER_NOT_FOUND" }) + } + 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, + }) + return { token, url: `${origin}${basePath}${passwordReset?.path || "/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..eb2597dd --- /dev/null +++ b/packages/core/src/session/stateful/reset-password.ts @@ -0,0 +1,36 @@ +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): SessionStrategy["resetPassword"] => { + const { sessionConfig } = ctx + return async ({ token, newPassword }) => { + 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) + await sessionConfig.adapter.revokeAllSessions(verificationToken.userId, "password_changed") + } +} 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..5ff1fe90 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): SessionStrategy["forgotPassword"] => { + return async () => { + throw new Error("forgotPassword is not supported in stateless mode.") + } +} + +export const resetPassword = (_: InternalStatelessContext): SessionStrategy["resetPassword"] => { + return async () => { + 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..7b1b2032 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(1), +}) + +export const ResetPasswordResponseSchema = object({ + success: boolean(), + redirect: boolean(), + redirectURL: string().nullable(), +}) 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..21394338 --- /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: false, + redirect: false, + redirectURL: null, + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") + expect(createTokenMock).not.toHaveBeenCalled() + expect(sendPasswordResetEmailMock).not.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..c5e367a8 --- /dev/null +++ b/packages/core/test/actions/password/reset.test.ts @@ -0,0 +1,331 @@ +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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") + }) + + 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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") + }) + + 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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + 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 new file mode 100644 index 00000000..2255a2ed --- /dev/null +++ b/packages/core/test/api/stateful/forgot-password.test.ts @@ -0,0 +1,240 @@ +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: false, + error: { + code: "SILENT_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), + toResponse: expect.any(Function), + }) + + expect(getUserByEmailMock).toHaveBeenCalledWith("nonexistent@example.com") + expect(createTokenMock).not.toHaveBeenCalled() + expect(sendPasswordResetEmailMock).not.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..6bfa69ab --- /dev/null +++ b/packages/core/test/api/stateful/reset-password.test.ts @@ -0,0 +1,336 @@ +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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") + }) + + 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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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() + expect(revokeAllSessionsMock).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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + expect(revokeAllSessionsMock).toHaveBeenCalledWith("user-123", "password_changed") + }) + + 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 revokeAllSessionsMock = 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, + revokeAllSessions: revokeAllSessionsMock, + }) + + 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") + 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." + ) + }) +}) 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, +}