Skip to content
Open
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
74 changes: 45 additions & 29 deletions index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -29,6 +29,34 @@ 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 async checkIfChallengeInBlackList(challenge: string): Promise<boolean> {
const res = await this.options.passkeys.keyValueAdapter.get(challenge);
if (!res) {
this.saveChallengeToStorage(challenge, this.options.passkeys?.challengeValidityPeriod || '2m');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yaroslav8765 "check" functions should not mutate state, this does not sound good,

100% better reading would be checkIfChellengeNotUsed() and separate method useChellenge()

return true;
}
return false;
}

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.checkIfChallengeInBlackList(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) {
Expand Down Expand Up @@ -171,18 +199,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) {

Expand Down Expand Up @@ -340,6 +361,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 ) {
Expand Down Expand Up @@ -529,15 +554,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;
}
Expand Down Expand Up @@ -572,14 +593,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;
Expand Down Expand Up @@ -612,7 +628,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' };
}
Expand Down
9 changes: 8 additions & 1 deletion types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AdminUser } from "adminforth";
import { AdminUser, KeyValueAdapter } from "adminforth";

export type PluginOptions = {

Expand Down Expand Up @@ -33,6 +33,13 @@ export type PluginOptions = {
credentialMetaFieldName: string,
credentialUserIdFieldName: string,


/**
* KeyValueAdapter is required to make sure that generated challenge can't be reused more than once
*/
keyValueAdapter: KeyValueAdapter,


/**
* Allow login with Passkeys even if 2FA is not set up. Default is true.
*/
Expand Down