From 52d97ab7815899e6e17770cf892221541a3c46ae Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Fri, 20 Feb 2026 12:15:10 +0200 Subject: [PATCH 1/3] feat: require key-value adapter for storing generated passkey challange --- index.ts | 76 +++++++++++++++++++++++++++++++++++--------------------- types.ts | 8 +++++- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/index.ts b/index.ts index 551482e..6c72775 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra } from "adminforth"; +import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra, convertPeriodToSeconds } from "adminforth"; import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth"; import twofactor from 'node-2fa'; import { PluginOptions } from "./types.js" @@ -29,6 +29,35 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return `single`; } + private saveChallengeToStorage(challenge: string, expiresIn?: string): void { + const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined; + this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds); + } + + private deleteChallengeFromStorage(challenge: string): void { + this.options.passkeys.keyValueAdapter.delete(challenge); + } + + private async checkIfChallengeGeneratedByBackend(challenge: string): Promise { + const res = await this.options.passkeys.keyValueAdapter.get(challenge); + this.deleteChallengeFromStorage(challenge); + return !!res; + } + + private async validateCookiesForPasskeyLogin(cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> { + const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); + if (!passkeysCookies) { + return { ok: false, error: 'Passkey token is required' }; + } + + const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); + const isChallangeValid = await this.checkIfChallengeGeneratedByBackend(decodedPasskeysCookies.challenge); + + if (!decodedPasskeysCookies || !isChallangeValid) { + return { ok: false, error: 'Invalid passkey' }; + } + return { ok: true, decodedPasskeysCookies }; + } public async checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{ skipAllowed: boolean }> { if (this.options.usersFilterToAllowSkipSetup) { @@ -171,18 +200,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: "Wrong or expired OTP code" } }; } else if (confirmationResult.mode === "passkey") { - //TODO: fix ts-ignore after releasing new version of adminforth with updated types - //@ts-ignore - const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); - if (!passkeysCookies) { - return { error: 'Passkey token is required' }; - } - - const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); - if (!decodedPasskeysCookies) { - return { error: 'Invalid passkey' }; + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } - const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, decodedPasskeysCookies ); + const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies ); if (verificationResult.ok && verificationResult.passkeyConfirmed) { @@ -340,6 +362,10 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { throw new Error('Passkeys credentialIdFieldName is required'); } + if (!this.options.passkeys.keyValueAdapter) { + throw new Error('Passkeys keyValueAdapter is required'); + } + const credentialResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID); const credentialIDField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialIdFieldName); if ( !credentialIDField ) { @@ -529,15 +555,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { let verified = null; if (body.usePasskey && this.options.passkeys) { // passkeys are enabled and user wants to use them - const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`}); - if (!passkeysCookies) { - return { error: 'Passkey token is required' }; + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } - const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); - if (!decodedPasskeysCookies) { - return { error: 'Invalid passkey' }; - } - const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies); + const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies); if (res.ok && res.passkeyConfirmed) { verified = true; } @@ -572,14 +594,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'Passkey response is required' }; } - const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyLoginTemporaryJWT"}); - if (!totpTemporaryJWT) { - return { error: 'Authentication session is expired. Please, try again' } - } - - const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempLoginPasskeyChallenge', false); - if (!decoded) { - return { error: 'Authentication session is expired. Please, try again' } + const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies); + if (!cookiesValidationResult.ok) { + return { error: cookiesValidationResult.error }; } let parsedPasskeyResponse; @@ -612,7 +629,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { return { error: 'User not found' }; } - const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, decoded); + const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies); if (!verificationResult.ok || !verificationResult.passkeyConfirmed) { return { error: 'Passkey verification failed' }; } @@ -887,6 +904,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required" }); const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempLoginPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m'); + this.saveChallengeToStorage(options.challenge, this.options.passkeys?.challengeValidityPeriod || '1m'); this.adminforth.auth.setCustomCookie({response, payload: {name: `passkeyLoginTemporaryJWT`, value: value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}}); return { ok: true, data: options }; } catch (e) { diff --git a/types.ts b/types.ts index 9ce4588..528baf1 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,4 @@ -import { AdminUser } from "adminforth"; +import { AdminUser, KeyValueAdapter } from "adminforth"; export type PluginOptions = { @@ -33,6 +33,12 @@ export type PluginOptions = { credentialMetaFieldName: string, credentialUserIdFieldName: string, + /** + * KeyValueAdapter is required to make sure + */ + keyValueAdapter: KeyValueAdapter, + + /** * Allow login with Passkeys even if 2FA is not set up. Default is true. */ From d6da1dfc04e2c85e20cc4302010bdf4892fdb2e7 Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Tue, 24 Feb 2026 14:36:56 +0200 Subject: [PATCH 2/3] fix: update challenge handling to have blacklist of used challenges --- index.ts | 16 +++++++--------- types.ts | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 6c72775..f192a20 100644 --- a/index.ts +++ b/index.ts @@ -34,14 +34,13 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds); } - private deleteChallengeFromStorage(challenge: string): void { - this.options.passkeys.keyValueAdapter.delete(challenge); - } - - private async checkIfChallengeGeneratedByBackend(challenge: string): Promise { + private async checkIfChallengeInBlackList(challenge: string): Promise { const res = await this.options.passkeys.keyValueAdapter.get(challenge); - this.deleteChallengeFromStorage(challenge); - return !!res; + if (!res) { + this.saveChallengeToStorage(challenge, this.options.passkeys?.challengeValidityPeriod || '2m'); + return true; + } + return false; } private async validateCookiesForPasskeyLogin(cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> { @@ -51,7 +50,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { } const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false); - const isChallangeValid = await this.checkIfChallengeGeneratedByBackend(decodedPasskeysCookies.challenge); + const isChallangeValid = await this.checkIfChallengeInBlackList(decodedPasskeysCookies.challenge); if (!decodedPasskeysCookies || !isChallangeValid) { return { ok: false, error: 'Invalid passkey' }; @@ -904,7 +903,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin { userVerification: this.options.passkeys?.settings.authenticatorSelection.userVerification || "required" }); const value = this.adminforth.auth.issueJWT({ "challenge": options.challenge }, 'tempLoginPasskeyChallenge', this.options.passkeys?.challengeValidityPeriod || '1m'); - this.saveChallengeToStorage(options.challenge, this.options.passkeys?.challengeValidityPeriod || '1m'); this.adminforth.auth.setCustomCookie({response, payload: {name: `passkeyLoginTemporaryJWT`, value: value, expiry: undefined, expirySeconds: 10 * 60, httpOnly: true}}); return { ok: true, data: options }; } catch (e) { diff --git a/types.ts b/types.ts index 528baf1..18cc4a3 100644 --- a/types.ts +++ b/types.ts @@ -34,7 +34,7 @@ export type PluginOptions = { credentialUserIdFieldName: string, /** - * KeyValueAdapter is required to make sure + * KeyValueAdapter is required to make sure that generated challenge can't be reused more than once */ keyValueAdapter: KeyValueAdapter, From 2ce581cae0d2add82af783f5bcd05307adec5e1c Mon Sep 17 00:00:00 2001 From: yaroslav8765 Date: Tue, 24 Feb 2026 14:40:44 +0200 Subject: [PATCH 3/3] fix!: force user to use KeyValueAdapter for the improved sequrity BREAKING CHANGE: KeyValue adapter is required for passkeys. --- types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types.ts b/types.ts index 18cc4a3..349c16d 100644 --- a/types.ts +++ b/types.ts @@ -33,6 +33,7 @@ export type PluginOptions = { credentialMetaFieldName: string, credentialUserIdFieldName: string, + /** * KeyValueAdapter is required to make sure that generated challenge can't be reused more than once */