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 `cancelEmailChange()` server API function and the `GET /email/change/cancel` endpoint to allow users to cancel pending email address changes. [#296](https://github.com/aura-stack-ts/auth/pull/296)

- Added the `changeEmail()` and `verifyEmailChange()` server API functions to support email address changes and email verification. [#295](https://github.com/aura-stack-ts/auth/pull/295)

- Added the `changePassword()` server API function and the `POST /password/change` endpoint for client-side password changes. The endpoint implements CSRF token validation to protect against cross-site request forgery (CSRF) attacks. [#294](https://github.com/aura-stack-ts/auth/pull/294)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/@types/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export interface MFAChallengesAdapter {
export interface VerificationTokensAdapter {
createToken(input: Omit<VerificationTokenEntity, "id" | "createdAt" | "revokedAt">): Promise<VerificationTokenEntity>
getTokenByHash(tokenHash: string): Promise<VerificationTokenEntity | null>
getTokensByUserId(userId: string): Promise<VerificationTokenEntity[]>
revokeToken(id: string): Promise<void>
}

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/@types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,15 @@ export type VerifyEmailChangeData =
| { success: false; redirect: false; redirectURL: null }

export type VerifyEmailChangeAPIReturn = AuthActionAPIReturn<VerifyEmailChangeData>

export type CancelEmailChangeAPIOptions = APIOptionsWithRequest &
APIOptionsWithRedirectTo &
APIOptionsWithSkipCSRFCheck & { payload?: { token: string } }

export type CancelEmailChangeData =
| { success: true; redirect: true; redirectURL: null }
| { success: true; redirect: false; redirectURL: string }
| { success: true; redirect: false; redirectURL: null }
| { success: false; redirect: false; redirectURL: null }

export type CancelEmailChangeAPIReturn = AuthActionAPIReturn<CancelEmailChangeData>
51 changes: 45 additions & 6 deletions packages/core/src/@types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,20 +590,58 @@ export interface EmailChangeConfig {
* token, and verification URL.
* You must implement this function to send the email using your preferred email service.
* @example
* sendEmailChangeVerification: async ({ email, token, url }) => {
* sendEmailChangeVerification: async ({ email, confirmToken, confirmURL }) => {
* // Implement your email sending logic here
* sendEmail({
* to: email,
* subject: "Email Change Verification",
* html: `<p>Click <a href="${url}">here</a> to verify your new email address.</p>`,
* html: `<p>Click <a href="${confirmURL}">here</a> to verify your new email address.</p>`,
* })
* }
*/
sendEmailChangeVerification?: (options: {
email?: string
/**
* The current email address of the user requesting the change.
*/
email: string
/**
* The new email address that the user wants to change to.
*/
newEmail: string
token: string | null
url: string | null
/**
* The token generated for verifying the email change request. It can be used
* by `verifyEmailChange` API to confirm the email change.
* @example
* confirmToken: "abc123def456ghi789"
*
* verifyEmailChange({ token: "abc123def456ghi789" })
*/
confirmToken: string
/**
* The URL that the user should visit to confirm the email change.
* @example
* confirmToken: "abc123def456ghi789"
*
* confirmURL: "https://example.com/auth/email/change/verify?token=abc123def456ghi789"
*/
confirmURL: string
/**
* The token generated for canceling the email change request. It can be used by
* `cancelEmailChange` API to cancel the email change request.
* @example
* cancelToken: "xyz789"
*
* cancelEmailChange({ token: "xyz789" })
*/
cancelToken: string
/**
* The URL that the user should visit to cancel the email change request.
* @example
* cancelToken: "xyz789"
*
* cancelURL: "https://example.com/auth/email/change/cancel?token=xyz789"
*/
cancelURL: string
}) => Awaitable<void>
}

Expand All @@ -629,7 +667,8 @@ export type RateLimiterConfig = Partial<
| "resetPassword"
| "changePassword"
| "changeEmail"
| "verifyEmailChange",
| "verifyEmailChange"
| "cancelEmailChange",
RateLimiterRule
>
>["rules"]
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/@types/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ export type MFAChallengeStatus = "pending" | "verified" | "expired" | "failed"

export type MfaCredentialStatus = "active" | "pending" | "disabled"

export type VerificationTokenType = "email_verification" | "password_reset" | "email_change" | "mfa_recovery"
export type VerificationTokenType =
| "email_verification"
| "password_reset"
| "email_change"
| "mfa_recovery"
| "email_change_cancel"
Comment thread
halvaradop marked this conversation as resolved.
Resolved

export interface UserEntity {
id: string
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/@types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,13 +365,16 @@ export interface SessionStrategy<DefaultUser extends User = User> {

changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void>

changeEmail(options: { userId: string; currentPassword: string; newEmail: string; origin: string }): Promise<{
token: string
url: string
oldEmail?: string
}>
changeEmail(options: {
userId: string
currentPassword: string
newEmail: string
origin: string
}): Promise<{ email: string; confirmToken: string; confirmURL: string; cancelToken: string; cancelURL: string }>

verifyEmailChange(options: { token: string; userId: string; sessionId: string }): Promise<void>

cancelEmailChange(options: { token: string; userId?: string } | { token?: string; userId: string }): Promise<void>
}

export interface OAuthTokenPayload {
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/actions/email/cancel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createEndpoint, createEndpointConfig } from "@aura-stack/router"
import { cancelEmailChange } from "@/api/cancel-email-change.ts"
import { CancelEmailChangeResponseSchema, CancelEmailChangeSearchParamsSchema } from "@/shared/schemas/actions.ts"

const cancelEmailChangeActionConfig = () => {
return createEndpointConfig({
schemas: {
searchParams: CancelEmailChangeSearchParamsSchema,
response: CancelEmailChangeResponseSchema,
},
hooks: {
onError: (ctx) => {
return ctx.json(
{
type: "VALIDATION",
code: "INVALID_VERIFICATION_TOKEN",
message: "Invalid or expired verification token",
},
{ status: 422 }
)
},
},
})
}

export const cancelEmailChangeAction = () => {
return createEndpoint(
"GET",
"/email/change/cancel",
async (ctx) => {
const { toResponse } = await cancelEmailChange({
ctx: ctx.context,
payload: ctx.searchParams.token ? { token: ctx.searchParams.token } : undefined,
request: ctx.request,
headers: ctx.request.headers,
redirect: ctx.searchParams.redirect,
redirectTo: ctx.searchParams.redirectTo,
skipCSRFCheck: false,
})
return toResponse()
},
cancelEmailChangeActionConfig()
)
}
1 change: 1 addition & 0 deletions packages/core/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export { resetPasswordAction } from "@/actions/password/reset.ts"
export { changePasswordAction } from "@/actions/password/change.ts"
export { changeEmailAction } from "@/actions/email/change.ts"
export { verifyEmailChangeAction } from "@/actions/email/verify.ts"
export { cancelEmailChangeAction } from "@/actions/email/cancel.ts"
105 changes: 105 additions & 0 deletions packages/core/src/api/cancel-email-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { HeadersBuilder } from "@aura-stack/router"
import { secureApiHeaders, toStandardizedHeaders } from "@/shared/http/headers.ts"
import { createValidation, errorToLogMessage, handleApiError, resolveApiRedirect } from "@/shared/utils/api.ts"
import type { FunctionAPIContext } from "@/@types/internal.ts"
import type { CancelEmailChangeAPIOptions, CancelEmailChangeAPIReturn } from "@/@types/api.ts"

export const cancelEmailChange = async ({
ctx,
payload,
headers: headersInit,
request: requestInit,
redirectTo,
redirect: redirectInit = true,
skipCSRFCheck = false,
doubleSubmitToken = undefined,
}: FunctionAPIContext<CancelEmailChangeAPIOptions>): Promise<CancelEmailChangeAPIReturn> => {
const { logger, sessionStrategy } = ctx
try {
logger?.log("CANCEL_EMAIL_CHANGE_INITIATED", {
structuredData: {
skipCSRFCheck: skipCSRFCheck,
doubleSubmitToken: doubleSubmitToken ? "provided" : "not_provided",
hasToken: Boolean(payload?.token),
},
})

const validation = createValidation(ctx, toStandardizedHeaders(headersInit ?? requestInit?.headers ?? {}))
.buildRequest(requestInit, "/email/change/cancel")
.verifyRateLimit("cancelEmailChange")

if (payload?.token) {
const { request, rateLimit } = await validation.execute()
requestInit = requestInit ?? request

if (rateLimit) {
return rateLimit
}
Comment thread
halvaradop marked this conversation as resolved.
Resolved

await sessionStrategy.cancelEmailChange({
token: payload?.token,
userId: undefined,
})
} else {
const { request, rateLimit, userId } = await validation
.verifyCSRFToken(skipCSRFCheck, doubleSubmitToken)
.verifySession()
.execute()
requestInit = requestInit ?? request

if (rateLimit) {
return rateLimit
}

await sessionStrategy.cancelEmailChange({
token: undefined,
userId: userId!,
})
}

const headers = new HeadersBuilder(secureApiHeaders)
const { redirect, redirectURL } = await resolveApiRedirect(ctx, requestInit, redirectInit, redirectTo, headers)
const headersList = headers.toHeaders()

return {
success: true,
redirect,
redirectURL: redirectInit ? null : redirectURL,
headers: headersList,
toResponse: () => {
return Response.json(
{
success: true,
redirect,
redirectURL: redirect ? null : redirectURL,
},
{ headers: headersList, status: redirect ? 302 : 200 }
)
},
} as CancelEmailChangeAPIReturn
} catch (error) {
errorToLogMessage(error, "CANCEL_EMAIL_CHANGE_ERROR", logger)
const { errors, statusCode } = handleApiError(
error,
"CANCEL_EMAIL_CHANGE_ERROR",
"An error occurred while canceling the email change."
)
return {
success: false,
error: errors,
redirect: false,
redirectURL: null,
headers: new Headers(secureApiHeaders),
toResponse: () => {
return Response.json(
{
success: false,
redirect: false,
redirectURL: null,
},
{ headers: secureApiHeaders, status: statusCode }
)
},
}
}
}
11 changes: 9 additions & 2 deletions packages/core/src/api/change-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,20 @@ export const changeEmail = async ({
if (rateLimit) {
return rateLimit
}
const { token, url, oldEmail } = await sessionStrategy.changeEmail({
const { email, confirmToken, confirmURL, cancelToken, cancelURL } = await sessionStrategy.changeEmail({
userId: userId!,
origin: originURL,
currentPassword: payload.currentPassword,
newEmail: payload.newEmail,
})
await emailChange.sendEmailChangeVerification({ email: oldEmail, newEmail: payload.newEmail, token, url })
await emailChange.sendEmailChangeVerification({
email,
newEmail: payload.newEmail,
confirmToken,
confirmURL,
cancelToken,
cancelURL,
})

const headers = new HeadersBuilder(secureApiHeaders)
const { redirect, redirectURL } = await resolveApiRedirect(ctx, request, redirectInit, redirectTo, headers)
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/api/create-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
changePassword,
changeEmail,
verifyEmailChange,
cancelEmailChange,
} from "@/api/index.ts"
import type { GlobalContext, InferSchema } from "@aura-stack/router"
import type {
Expand Down Expand Up @@ -64,6 +65,8 @@ import type {
ChangeEmailAPIReturn,
VerifyEmailChangeAPIOptions,
VerifyEmailChangeAPIReturn,
CancelEmailChangeAPIOptions,
CancelEmailChangeAPIReturn,
} from "@/@types/index.ts"
import type { ZodObject } from "zod"
import type { SchemaTypes } from "@/identity/index.ts"
Expand Down Expand Up @@ -409,6 +412,33 @@ export const createAuthAPI = <DefaultUser extends User = User, SignUpSchema exte
verifyEmailChange: async (options: VerifyEmailChangeAPIOptions): Promise<VerifyEmailChangeAPIReturn> => {
return verifyEmailChange({ ctx, skipCSRFCheck: true, ...options })
},
/**
* Cancels an email change request on the server-side. It supports two modes of operation:
* - passing a `payload.token` to cancel the email change request directly (e.g., from a link in an email)
* - without a token, in which case it cancels the email change request for the currently authenticated user.
*
* > **NOTE**: This method is intended for authenticated users who have requested an email change and
* received a cancellation token or want to cancel the email change request for their own account.
*
* @param options - Options for the API call, including the cancellation token (if applicable), headers, and request object.
* @returns The object returned by the API call {@link CancelEmailChangeAPIReturn}
* @example
* // Cancel email change via token (e.g., from a link in an email)
* const response = await api.cancelEmailChange({
* payload: {
* token: "email-change-cancellation-token-here"
* },
* headers: getHeaders()
* })
*
* // Cancel email change via application (for the currently authenticated user)
* const response = await api.cancelEmailChange({
* headers: getHeaders()
* })
*/
cancelEmailChange: async (options: CancelEmailChangeAPIOptions): Promise<CancelEmailChangeAPIReturn> => {
return cancelEmailChange({ ctx, skipCSRFCheck: true, ...options })
},
/**
* Signs out the current session on the server-side. It implements CSRF Protection by default, for
* server-side calls it only verifies and validates the CSRF Token, it also provides Double-Submit
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export { resetPassword } from "@/api/reset-password.ts"
export { changePassword } from "@/api/change-password.ts"
export { changeEmail } from "@/api/change-email.ts"
export { verifyEmailChange } from "@/api/verify-email-change.ts"
export { cancelEmailChange } from "@/api/cancel-email-change.ts"
2 changes: 2 additions & 0 deletions packages/core/src/config/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
changePasswordAction,
changeEmailAction,
verifyEmailChangeAction,
cancelEmailChangeAction,
} from "@/actions/index.ts"
import { createAuthAPI } from "@/api/index.ts"
import { createContext } from "@/config/context.ts"
Expand Down Expand Up @@ -77,6 +78,7 @@ export const createAuthInstance = <
changePasswordAction(),
changeEmailAction(),
verifyEmailChangeAction(),
cancelEmailChangeAction(),
],
config
)
Expand Down
Loading
Loading