Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/@types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,29 @@ export type VerifyEmailAPIOptions = APIOptionsWithRequest & APIOptionsWithRedire
export type VerifyEmailData = APIFunctionReturnData

export type VerifyEmailAPIReturn = AuthActionAPIReturn<VerifyEmailData>

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<ForgotPasswordData>

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<ResetPasswordData>
53 changes: 52 additions & 1 deletion packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: `<p>Click <a href="${url}">here</a> to reset your password.</p>`,
* })
* }
* }
*/
passwordReset?: PasswordResetConfig
} & TrustedProxyHeadersConfig

export type OAuthProvidersConfig<Identity extends Identities> =
Expand Down Expand Up @@ -513,6 +530,37 @@ export interface EmailVerificationConfig {
sendVerificationEmail?: (options: { email: string; token: string | null; url: string | null }) => Awaitable<void>
}

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<void>
}

// #region Rate Limiter

export type RateLimiterConfig = Partial<
Expand All @@ -530,7 +578,10 @@ export type RateLimiterConfig = Partial<
| "isProviderConnected"
| "signOut"
| "sendVerificationEmail"
| "verifyEmail",
| "verifyEmail"
| "forgotPassword"
| "resetPassword"
| "changePassword",
RateLimiterRule
>
>["rules"]
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/@types/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
EmailVerificationConfig,
JoseInstance,
LogLevel,
PasswordResetConfig,
RateLimiterConfig,
SignUpConfig,
TrustedOrigin,
Expand Down Expand Up @@ -92,6 +93,7 @@ export interface RouterGlobalContext<DefaultUser extends User = User, SignUpSche
rateLimiters: InferRules<Required<RateLimiterConfig>>
sessionConfig: SessionConfig
emailVerification?: EmailVerificationConfig
passwordReset?: PasswordResetConfig
Comment thread
halvaradop marked this conversation as resolved.
Resolved
}

export interface SchemaRegistryContext {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ export interface SessionStrategy<DefaultUser extends User = User> {
}>

verifyEmail(options: { token: string }): Promise<void>

forgotPassword(options: { email: string; origin: string }): Promise<{
token: string
url: string
}>

resetPassword(options: { token: string; newPassword: string }): Promise<void>
}

export interface OAuthTokenPayload {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
31 changes: 31 additions & 0 deletions packages/core/src/actions/password/forgot.ts
Original file line number Diff line number Diff line change
@@ -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()
)
}
44 changes: 44 additions & 0 deletions packages/core/src/actions/password/reset.ts
Original file line number Diff line number Diff line change
@@ -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()
)
}
4 changes: 2 additions & 2 deletions packages/core/src/actions/verifyEmail/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/api/create-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
isProviderConnected,
sendVerificationEmail,
verifyEmail,
forgotPassword,
resetPassword,
} from "@/api/index.ts"
import type { GlobalContext, InferSchema } from "@aura-stack/router"
import type {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -309,6 +315,38 @@ export const createAuthAPI = <DefaultUser extends User = User, SignUpSchema exte
verifyEmail: async (token: string, options?: VerifyEmailAPIOptions): Promise<VerifyEmailAPIReturn> => {
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<ForgotPasswordAPIReturn> => {
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<ResetPasswordAPIReturn> => {
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
Expand Down
78 changes: 78 additions & 0 deletions packages/core/src/api/forgot-password.ts
Original file line number Diff line number Diff line change
@@ -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<ForgotPasswordAPIOptions>
): Promise<ForgotPasswordAPIReturn> => {
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 })

Comment thread
halvaradop marked this conversation as resolved.
Resolved
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 }
)
},
}
}
}
2 changes: 2 additions & 0 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading