Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ Feat ]: Phone Support #65

Closed
wants to merge 3 commits into from
Closed
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
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ export const STRATEGY_NAME = 'TOTP'

export const FORM_FIELDS = {
EMAIL: 'email',
PHONE: 'phone',
CODE: 'code',
} as const

export const SESSION_KEYS = {
EMAIL: 'auth:email',
PHONE: 'auth:phone',
TOTP: 'auth:totp',
} as const

export const ERRORS = {
// Customizable errors.
REQUIRED_EMAIL: 'Email is required.',
INVALID_EMAIL: 'Email is not valid.',
REQUIRED_PHONE: 'Phone is required.',
INVALID_PHONE: 'Phone is not valid.',
INVALID_TOTP: 'Code is not valid.',
EXPIRED_TOTP: 'Code has expired.',

Expand Down
93 changes: 83 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ export interface SendTOTPOptions {
/**
* The email address provided by the user.
*/
email: string
email?: string

/**
* The phone number provided by the user.
*/
phone?: string

/**
* The decrypted TOTP code.
Expand Down Expand Up @@ -147,6 +152,15 @@ export interface ValidateEmail {
(email: string): Promise<boolean>
}

/**
* The validate phone method.
*
* @param phone The phone number to validate.
*/
export interface ValidatePhone {
(phone: string): Promise<boolean>
}

/**
* The custom errors configuration.
*/
Expand All @@ -155,12 +169,21 @@ export interface CustomErrorsOptions {
* The required email error message.
*/
requiredEmail?: string
/**
* The required phone error message.
*/
requiredPhone?: string

/**
* The invalid email error message.
*/
invalidEmail?: string

/**
* The invalid phone error message.
*/
invalidPhone?: string

/**
* The invalid TOTP error message.
*/
Expand Down Expand Up @@ -210,6 +233,12 @@ export interface TOTPStrategyOptions {
*/
emailFieldKey?: string

/**
* The form input name used to get the phone.
* @default "phone"
*/
phoneFieldKey?: string

/**
* The form input name used to get the TOTP.
* @default "code"
Expand All @@ -222,6 +251,12 @@ export interface TOTPStrategyOptions {
*/
sessionEmailKey?: string

/**
* The session key that stores the phone number.
* @default "auth:phone"
*/
sessionPhoneKey?: string

/**
* The session key that stores the signed TOTP.
* @default "auth:totp"
Expand All @@ -237,6 +272,11 @@ export interface TOTPStrategyOptions {
* The validate email method.
*/
validateEmail?: ValidateEmail

/**
* The validate phone method.
*/
validatePhone?: ValidatePhone
}

/**
Expand All @@ -247,7 +287,12 @@ export interface TOTPVerifyParams {
/**
* The email address provided by the user.
*/
email: string
email?: string

/**
* The phone number provided by the user.
*/
phone?: string

/**
* The formData object from the Request.
Expand Down Expand Up @@ -277,11 +322,14 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
private readonly magicLinkPath: string
private readonly customErrors: Required<CustomErrorsOptions>
private readonly emailFieldKey: string
private readonly phoneFieldKey: string
private readonly codeFieldKey: string
private readonly sessionEmailKey: string
private readonly sessionPhoneKey: string
private readonly sessionTotpKey: string
private readonly sendTOTP: SendTOTP
private readonly validateEmail: ValidateEmail
private readonly validatePhone: ValidatePhone
private readonly _totpGenerationDefaults = {
algorithm: 'SHA256', // More secure than SHA1
charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // No O or 0
Expand All @@ -292,6 +340,8 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
private readonly _customErrorsDefaults: Required<CustomErrorsOptions> = {
requiredEmail: ERRORS.REQUIRED_EMAIL,
invalidEmail: ERRORS.INVALID_EMAIL,
requiredPhone: ERRORS.REQUIRED_PHONE,
invalidPhone: ERRORS.INVALID_PHONE,
invalidTotp: ERRORS.INVALID_TOTP,
expiredTotp: ERRORS.EXPIRED_TOTP,
}
Expand All @@ -305,11 +355,14 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
this.maxAge = options.maxAge
this.magicLinkPath = options.magicLinkPath ?? '/magic-link'
this.emailFieldKey = options.emailFieldKey ?? FORM_FIELDS.EMAIL
this.phoneFieldKey = options.phoneFieldKey ?? FORM_FIELDS.PHONE
this.codeFieldKey = options.codeFieldKey ?? FORM_FIELDS.CODE
this.sessionEmailKey = options.sessionEmailKey ?? SESSION_KEYS.EMAIL
this.sessionPhoneKey = options.sessionPhoneKey ?? SESSION_KEYS.PHONE
this.sessionTotpKey = options.sessionTotpKey ?? SESSION_KEYS.TOTP
this.sendTOTP = options.sendTOTP
this.validateEmail = options.validateEmail ?? this._validateEmailDefault
this.validatePhone = options.validatePhone ?? this._validatePhoneDefault

this.totpGeneration = {
...this._totpGenerationDefaults,
Expand Down Expand Up @@ -352,19 +405,24 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {

const formData = await this._readFormData(request, options)
const formDataEmail = coerceToOptionalNonEmptyString(formData.get(this.emailFieldKey))
const formDataPhone = coerceToOptionalNonEmptyString(formData.get(this.phoneFieldKey))
const formDataCode = coerceToOptionalNonEmptyString(formData.get(this.codeFieldKey))
const sessionEmail = coerceToOptionalString(session.get(this.sessionEmailKey))
const sessionPhone = coerceToOptionalString(session.get(this.sessionPhoneKey))
const sessionTotp = coerceToOptionalTotpSessionData(session.get(this.sessionTotpKey))
const email =
request.method === 'POST'
? formDataEmail ?? (!formDataCode ? sessionEmail : null)
: null
const email = formDataEmail ?? (!formDataCode ? sessionEmail : undefined)
const phone = formDataPhone ?? (!formDataCode ? sessionPhone : undefined)

try {
if (email) {
const { code, jwe, magicLink } = await this._generateTOTP({ email, request })
if (request.method === 'POST' && (email || phone)) {
const { code, jwe, magicLink } = await this._generateTOTP({
email,
phone,
request,
})
await this.sendTOTP({
email,
phone,
code,
magicLink,
formData,
Expand Down Expand Up @@ -427,9 +485,19 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
}
}

private async _generateTOTP({ email, request }: { email: string; request: Request }) {
const isValidEmail = await this.validateEmail(email)
private async _generateTOTP({
email,
phone,
request,
}: {
email?: string
phone?: string
request: Request
}) {
const isValidEmail = email ? await this.validateEmail(email) : true
const isValidPhone = phone ? await this.validatePhone(phone) : true
if (!isValidEmail) throw new Error(this.customErrors.invalidEmail)
if (!isValidPhone) throw new Error(this.customErrors.invalidPhone)

const { otp: code, secret } = generateTOTP({
...this.totpGeneration,
Expand Down Expand Up @@ -472,6 +540,11 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
return regexEmail.test(email)
}

private async _validatePhoneDefault(phone: string) {
const regexPhone = /^\+[1-9]\d{1,14}$/ // Basic E.164 validation
return regexPhone.test(phone)
}

private async _validateTOTP({
code,
sessionTotp,
Expand Down