Skip to content

Commit ec93c1f

Browse files
authored
Merge pull request #20 from devforth/feature/AdminForth/1139/question-in-current-implementa
feat: require key-value adapter for storing generated passkey challange
2 parents cbe4693 + 44e56a6 commit ec93c1f

2 files changed

Lines changed: 55 additions & 30 deletions

File tree

index.ts

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra } from "adminforth";
1+
import { AdminForthPlugin, Filters, suggestIfTypo, HttpExtra, convertPeriodToSeconds } from "adminforth";
22
import type { AdminForthResource, AdminUser, IAdminForth, IHttpServer, IAdminForthAuth, BeforeLoginConfirmationFunction, IAdminForthHttpResponse } from "adminforth";
33
import twofactor from 'node-2fa';
44
import { PluginOptions } from "./types.js"
@@ -29,6 +29,36 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
2929
return `single`;
3030
}
3131

32+
private useChellenge(challenge: string, expiresIn?: string): void {
33+
const expiresInSeconds = expiresIn ? convertPeriodToSeconds(expiresIn) : undefined;
34+
this.options.passkeys.keyValueAdapter.set(challenge, 'stub_value', expiresInSeconds);
35+
}
36+
37+
private async checkIfChellengeNotUsed(challenge: string): Promise<boolean> {
38+
const res = await this.options.passkeys.keyValueAdapter.get(challenge);
39+
if (!res) {
40+
return true;
41+
}
42+
return false;
43+
}
44+
45+
private async validateCookiesForPasskeyLogin(cookies: any): Promise<{ok: boolean, decodedPasskeysCookies?: any, error?: string}> {
46+
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`});
47+
if (!passkeysCookies) {
48+
return { ok: false, error: 'Passkey token is required' };
49+
}
50+
51+
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
52+
const isChallangeValid = await this.checkIfChellengeNotUsed(decodedPasskeysCookies.challenge);
53+
if (isChallangeValid) {
54+
this.useChellenge(decodedPasskeysCookies.challenge, this.options.passkeys?.challengeValidityPeriod || '2m');
55+
}
56+
57+
if (!decodedPasskeysCookies || !isChallangeValid) {
58+
return { ok: false, error: 'Invalid passkey' };
59+
}
60+
return { ok: true, decodedPasskeysCookies };
61+
}
3262

3363
public async checkIfSkipSetupAllowSkipVerify(adminUser: AdminUser): Promise<{ skipAllowed: boolean }> {
3464
if (this.options.usersFilterToAllowSkipSetup) {
@@ -171,18 +201,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
171201
return { error: "Wrong or expired OTP code" }
172202
};
173203
} else if (confirmationResult.mode === "passkey") {
174-
//TODO: fix ts-ignore after releasing new version of adminforth with updated types
175-
//@ts-ignore
176-
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`});
177-
if (!passkeysCookies) {
178-
return { error: 'Passkey token is required' };
179-
}
180-
181-
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
182-
if (!decodedPasskeysCookies) {
183-
return { error: 'Invalid passkey' };
204+
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
205+
if (!cookiesValidationResult.ok) {
206+
return { error: cookiesValidationResult.error };
184207
}
185-
const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, decodedPasskeysCookies );
208+
const verificationResult = await this.verifyPasskeyResponse(confirmationResult.result, opts.userPk, cookiesValidationResult.decodedPasskeysCookies );
186209

187210
if (verificationResult.ok && verificationResult.passkeyConfirmed) {
188211

@@ -340,6 +363,10 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
340363
throw new Error('Passkeys credentialIdFieldName is required');
341364
}
342365

366+
if (!this.options.passkeys.keyValueAdapter) {
367+
throw new Error('Passkeys keyValueAdapter is required');
368+
}
369+
343370
const credentialResource = adminforth.config.resources.find(r => r.resourceId === this.options.passkeys.credentialResourceID);
344371
const credentialIDField = credentialResource.columns.find(c => c.name === this.options.passkeys.credentialIdFieldName);
345372
if ( !credentialIDField ) {
@@ -529,15 +556,11 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
529556
let verified = null;
530557
if (body.usePasskey && this.options.passkeys) {
531558
// passkeys are enabled and user wants to use them
532-
const passkeysCookies = this.adminforth.auth.getCustomCookie({cookies: cookies, name: `passkeyLoginTemporaryJWT`});
533-
if (!passkeysCookies) {
534-
return { error: 'Passkey token is required' };
559+
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
560+
if (!cookiesValidationResult.ok) {
561+
return { error: cookiesValidationResult.error };
535562
}
536-
const decodedPasskeysCookies = await this.adminforth.auth.verify(passkeysCookies, 'tempLoginPasskeyChallenge', false);
537-
if (!decodedPasskeysCookies) {
538-
return { error: 'Invalid passkey' };
539-
}
540-
const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, decodedPasskeysCookies);
563+
const res = await this.verifyPasskeyResponse(body.passkeyOptions, decoded.pk, cookiesValidationResult.decodedPasskeysCookies);
541564
if (res.ok && res.passkeyConfirmed) {
542565
verified = true;
543566
}
@@ -572,14 +595,9 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
572595
return { error: 'Passkey response is required' };
573596
}
574597

575-
const totpTemporaryJWT = this.adminforth.auth.getCustomCookie({cookies: cookies, name: "passkeyLoginTemporaryJWT"});
576-
if (!totpTemporaryJWT) {
577-
return { error: 'Authentication session is expired. Please, try again' }
578-
}
579-
580-
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempLoginPasskeyChallenge', false);
581-
if (!decoded) {
582-
return { error: 'Authentication session is expired. Please, try again' }
598+
const cookiesValidationResult = await this.validateCookiesForPasskeyLogin(cookies);
599+
if (!cookiesValidationResult.ok) {
600+
return { error: cookiesValidationResult.error };
583601
}
584602

585603
let parsedPasskeyResponse;
@@ -612,7 +630,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
612630
return { error: 'User not found' };
613631
}
614632

615-
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, decoded);
633+
const verificationResult = await this.verifyPasskeyResponse(passkeyResponse, userPk, cookiesValidationResult.decodedPasskeysCookies);
616634
if (!verificationResult.ok || !verificationResult.passkeyConfirmed) {
617635
return { error: 'Passkey verification failed' };
618636
}

types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AdminUser } from "adminforth";
1+
import { AdminUser, KeyValueAdapter } from "adminforth";
22

33
export type PluginOptions = {
44

@@ -33,6 +33,13 @@ export type PluginOptions = {
3333
credentialMetaFieldName: string,
3434
credentialUserIdFieldName: string,
3535

36+
37+
/**
38+
* KeyValueAdapter is required to make sure that generated challenge can't be reused more than once
39+
*/
40+
keyValueAdapter: KeyValueAdapter,
41+
42+
3643
/**
3744
* Allow login with Passkeys even if 2FA is not set up. Default is true.
3845
*/

0 commit comments

Comments
 (0)