Skip to content

Commit 02d8610

Browse files
committed
add expiry to url params, encrypt url params
1 parent b46dc66 commit 02d8610

File tree

3 files changed

+172
-49
lines changed

3 files changed

+172
-49
lines changed

src/constants.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ export const SESSION_KEYS = {
1212

1313
export const ERRORS = {
1414
// Customizable errors.
15-
REQUIRED_EMAIL: 'Email is required.',
16-
INVALID_EMAIL: 'Email is not valid.',
17-
INVALID_TOTP: 'Code is not valid.',
18-
EXPIRED_TOTP: 'Code has expired.',
15+
REQUIRED_EMAIL: 'Please enter your email address to continue.',
16+
INVALID_EMAIL:
17+
"That doesn't look like a valid email address. Please check and try again.",
18+
INVALID_TOTP:
19+
"That code didn't work. Please check and try again, or request a new code.",
20+
EXPIRED_TOTP: 'That code has expired. Please request a new one.',
1921
MISSING_SESSION_EMAIL:
20-
'Missing email to verify. Check that same browser used for verification.',
22+
"We couldn't find an email to verify. Please use the same browser you started with or restart from this browser.",
23+
MISSING_SESSION_TOTP:
24+
"We couldn't find an active verification session. Please request a new code.",
25+
RATE_LIMIT_EXCEEDED: "Too many incorrect attempts. Please request a new code.",
2126

2227
// Miscellaneous errors.
2328
REQUIRED_ENV_SECRET: 'Missing required .env secret.',

src/index.ts

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
392410
export 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

Comments
 (0)