@@ -154,6 +154,11 @@ export interface CustomErrorsOptions {
154154 */
155155 invalidTotp ?: string
156156
157+ /**
158+ * The rate limit exceeded error message.
159+ */
160+ rateLimitExceeded ?: string
161+
157162 /**
158163 * The expired TOTP error message.
159164 */
@@ -163,6 +168,11 @@ export interface CustomErrorsOptions {
163168 * The missing session email error message.
164169 */
165170 missingSessionEmail ?: string
171+
172+ /**
173+ * The missing session totp error message.
174+ */
175+ missingSessionTotp ?: string
166176}
167177
168178/**
@@ -389,6 +399,14 @@ class TOTPStore {
389399 }
390400}
391401
402+ /**
403+ * Interface for encrypted magic link parameters
404+ */
405+ interface MagicLinkParams {
406+ code : string
407+ expires : number
408+ }
409+
392410export class TOTPStrategy < User > extends Strategy < User , TOTPVerifyParams > {
393411 public name = STRATEGY_NAME
394412
@@ -416,8 +434,10 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
416434 requiredEmail : ERRORS . REQUIRED_EMAIL ,
417435 invalidEmail : ERRORS . INVALID_EMAIL ,
418436 invalidTotp : ERRORS . INVALID_TOTP ,
437+ rateLimitExceeded : ERRORS . RATE_LIMIT_EXCEEDED ,
419438 expiredTotp : ERRORS . EXPIRED_TOTP ,
420439 missingSessionEmail : ERRORS . MISSING_SESSION_EMAIL ,
440+ missingSessionTotp : ERRORS . MISSING_SESSION_TOTP ,
421441 }
422442
423443 constructor (
@@ -546,15 +566,20 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
546566 } )
547567 }
548568
549- // Get the TOTP code, either from form data or magic link.
550- const code = formDataCode ?? this . _getMagicLinkCode ( request )
569+ // Get the TOTP code and expiry, either from form data or magic link.
570+ const { code : linkCode , expires : linkExpires } = await this . _getMagicLinkCode (
571+ request ,
572+ sessionTotp ,
573+ )
574+
575+ const code = formDataCode ?? linkCode
551576
552577 if ( code ) {
553578 if ( ! sessionEmail ) throw new Error ( this . customErrors . missingSessionEmail )
554- if ( ! sessionTotp ) throw new Error ( this . customErrors . expiredTotp )
579+ if ( ! sessionTotp ) throw new Error ( this . customErrors . missingSessionTotp )
555580
556581 // Validate the TOTP.
557- await this . _validateTOTP ( { code, sessionTotp, store } )
582+ await this . _validateTOTP ( { code, sessionTotp, store, urlExpires : linkExpires } )
558583
559584 // Clear TOTP data since user verified successfully.
560585 store . setEmail ( undefined )
@@ -581,6 +606,12 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
581606 } )
582607 }
583608 if ( err instanceof Error ) {
609+ if (
610+ err . message === this . customErrors . rateLimitExceeded ||
611+ err . message === this . customErrors . expiredTotp
612+ ) {
613+ store . setTOTP ( undefined )
614+ }
584615 store . setError ( err . message )
585616 throw redirect ( this . _failureRedirect , {
586617 headers : {
@@ -609,26 +640,37 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
609640 * @param code - The TOTP code.
610641 * @param sessionTotp - The TOTP session data.
611642 * @param store - The TOTP store.
643+ * @param urlExpires - The TOTP code expiry date in milliseconds.
612644 */
613645 private async _validateTOTP ( {
614646 code,
615647 sessionTotp,
616648 store,
649+ urlExpires,
617650 } : {
618651 code : string
619652 sessionTotp : TOTPCookieData
620653 store : TOTPStore
654+ urlExpires ?: number
621655 } ) {
622656 try {
623- // https://github.com/panva/jose/blob/main/docs/functions/jwe_compact_decrypt.compactDecrypt.md
657+ // Check if the TOTP is expired from the URL
658+ if ( urlExpires ) {
659+ const dateNow = Date . now ( )
660+ if ( dateNow > urlExpires ) {
661+ throw new Error ( this . customErrors . expiredTotp )
662+ }
663+ }
664+
665+ // https://github.com/panva/jose/blob/main/docs/jwe/compact/decrypt/functions/compactDecrypt.md
624666 const { plaintext } = await jose . compactDecrypt (
625667 sessionTotp . jwe ,
626668 asJweKey ( this . secret ) ,
627669 )
628670 const totpData = JSON . parse ( new TextDecoder ( ) . decode ( plaintext ) )
629671 assertTOTPData ( totpData )
630672
631- // Check if the TOTP is expired.
673+ // Check if the TOTP is expired from the Cookie .
632674 const dateNow = Date . now ( )
633675 const isExpired = dateNow - totpData . createdAt > this . totpGeneration . period * 1000
634676 if ( isExpired ) {
@@ -649,14 +691,9 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
649691 store . setTOTP ( undefined )
650692 store . setError ( this . customErrors . expiredTotp )
651693 } else {
652- sessionTotp . attempts += 1
653- // Check if the user has reached the max number of attempts.
654- if ( sessionTotp . attempts >= this . totpGeneration . maxAttempts ) {
655- store . setTOTP ( undefined )
656- } else {
657- store . setTOTP ( sessionTotp )
658- }
659- store . setError ( this . customErrors . invalidTotp )
694+ store . setError (
695+ error instanceof Error ? error . message : this . customErrors . invalidTotp ,
696+ )
660697 }
661698
662699 // Redirect to the failure URL with the updated store.
@@ -690,7 +727,7 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
690727 . setProtectedHeader ( { alg : 'dir' , enc : 'A256GCM' } )
691728 . encrypt ( asJweKey ( this . secret ) )
692729
693- const magicLink = this . _generateMagicLink ( { code, request } )
730+ const magicLink = await this . _generateMagicLink ( { code, request } )
694731
695732 return {
696733 code,
@@ -699,15 +736,71 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
699736 }
700737 }
701738
739+ /**
740+ * Encrypts magic link parameters
741+ * @param params - The parameters to encrypt
742+ * @returns The encrypted JWE token
743+ */
744+ private async _encryptUrlParams ( params : MagicLinkParams ) : Promise < string > {
745+ const payload = new TextEncoder ( ) . encode ( JSON . stringify ( params ) )
746+ return await new jose . CompactEncrypt ( payload )
747+ . setProtectedHeader ( { alg : 'dir' , enc : 'A256GCM' } )
748+ . encrypt ( asJweKey ( this . secret ) )
749+ }
750+
751+ /**
752+ * Decrypts and validates magic link parameters
753+ * @param encrypted - The encrypted JWE token
754+ * @returns The decrypted and validated parameters
755+ */
756+ private async _decryptUrlParams (
757+ encrypted : string ,
758+ sessionTotp ?: TOTPCookieData ,
759+ ) : Promise < MagicLinkParams > {
760+ try {
761+ const { plaintext } = await jose . compactDecrypt ( encrypted , asJweKey ( this . secret ) )
762+ const params = JSON . parse ( new TextDecoder ( ) . decode ( plaintext ) )
763+
764+ if ( ! params ?. code || ! params ?. expires || typeof params . expires !== 'number' ) {
765+ throw new Error ( 'Invalid magic link format' )
766+ }
767+
768+ return params
769+ } catch ( error ) {
770+ if ( ! sessionTotp || sessionTotp . attempts < this . totpGeneration . maxAttempts ) {
771+ if ( sessionTotp ) {
772+ sessionTotp . attempts += 1
773+ }
774+ throw new Error ( this . customErrors . invalidTotp )
775+ }
776+
777+ throw new Error ( this . customErrors . rateLimitExceeded )
778+ }
779+ }
780+
702781 /**
703782 * Generates the magic link.
704783 * @param code - The TOTP code.
705784 * @param request - The request object.
706785 * @returns The magic link.
707786 */
708- private _generateMagicLink ( { code, request } : { code : string ; request : Request } ) {
787+ private async _generateMagicLink ( {
788+ code,
789+ request,
790+ } : {
791+ code : string
792+ request : Request
793+ } ) {
709794 const url = new URL ( this . magicLinkPath ?? '/' , new URL ( request . url ) . origin )
710- url . searchParams . set ( this . codeFieldKey , code )
795+
796+ const params : MagicLinkParams = {
797+ code,
798+ expires : Date . now ( ) + this . totpGeneration . period * 1000 ,
799+ }
800+
801+ const encrypted = await this . _encryptUrlParams ( params )
802+ url . searchParams . set ( 't' , encrypted )
803+
711804 return url . toString ( )
712805 }
713806
@@ -716,17 +809,32 @@ export class TOTPStrategy<User> extends Strategy<User, TOTPVerifyParams> {
716809 * @param request - The request object.
717810 * @returns The magic link code.
718811 */
719- private _getMagicLinkCode ( request : Request ) {
812+ private async _getMagicLinkCode (
813+ request : Request ,
814+ sessionTotp ?: TOTPCookieData ,
815+ ) : Promise < { code ?: string ; expires ?: number } > {
720816 if ( request . method === 'GET' ) {
721817 const url = new URL ( request . url )
722818 if ( url . pathname !== this . magicLinkPath ) {
723819 throw new Error ( ERRORS . INVALID_MAGIC_LINK_PATH )
724820 }
725- if ( url . searchParams . has ( this . codeFieldKey ) ) {
726- return decodeURIComponent ( url . searchParams . get ( this . codeFieldKey ) ?? '' )
821+
822+ const token = url . searchParams . get ( 't' )
823+ if ( ! token ) {
824+ return { }
825+ }
826+
827+ try {
828+ const params = await this . _decryptUrlParams ( token , sessionTotp )
829+ return {
830+ code : params . code ,
831+ expires : params . expires ,
832+ }
833+ } catch ( error ) {
834+ throw error
727835 }
728836 }
729- return undefined
837+ return { }
730838 }
731839
732840 /**
0 commit comments