Skip to content

Commit

Permalink
feat(core): implement the webauthn verification
Browse files Browse the repository at this point in the history
implement the webauthn verification
  • Loading branch information
simeng-li committed Jul 25, 2024
1 parent 9d2770c commit 40328e7
Show file tree
Hide file tree
Showing 7 changed files with 487 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export class MfaValidator {
// Filter out the verified MFA verification records
const mfaVerificationRecords = verificationRecords.filter(({ type, isVerified }) => {
return (
isVerified &&
isMfaVerificationRecordType(type) &&
isVerified &&

Check warning on line 114 in packages/core/src/routes/experience/classes/libraries/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/libraries/mfa-validator.ts#L114

Added line #L114 was not covered by tests
// Check if the verification type is enabled in the user's MFA settings
enabledMfaFactors.some((factor) => factor.type === mfaVerificationTypeToMfaFactorMap[type])
);
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import {
type TotpVerificationRecordData,
} from './totp-verification.js';
import { type VerificationRecord as GenericVerificationRecord } from './verification-record.js';
import {
WebAuthnVerification,
webAuthnVerificationRecordDataGuard,
type WebAuthnVerificationRecordData,
} from './web-authn.js';

export type VerificationRecordData =
| PasswordVerificationRecordData
Expand All @@ -51,6 +56,7 @@ export type VerificationRecordData =
| EnterpriseSsoVerificationRecordData
| TotpVerificationRecordData
| BackupCodeVerificationRecordData
| WebAuthnVerificationRecordData
| NewPasswordIdentityVerificationRecordData;

// This is to ensure the keys of the map are the same as the type of the verification record
Expand All @@ -67,6 +73,7 @@ export type VerificationRecordMap = AssertVerificationMap<{
[VerificationType.EnterpriseSso]: EnterpriseSsoVerification;
[VerificationType.TOTP]: TotpVerification;
[VerificationType.BackupCode]: BackupCodeVerification;
[VerificationType.WebAuthn]: WebAuthnVerification;
[VerificationType.NewPasswordIdentity]: NewPasswordIdentityVerification;
}>;

Expand All @@ -89,6 +96,7 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [
enterPriseSsoVerificationRecordDataGuard,
totpVerificationRecordDataGuard,
backupCodeVerificationRecordDataGuard,
webAuthnVerificationRecordDataGuard,
newPasswordIdentityVerificationRecordDataGuard,
]);

Expand Down Expand Up @@ -122,6 +130,9 @@ export const buildVerificationRecord = (
case VerificationType.BackupCode: {
return new BackupCodeVerification(libraries, queries, data);
}
case VerificationType.WebAuthn: {
return new WebAuthnVerification(libraries, queries, data);
}

Check warning on line 135 in packages/core/src/routes/experience/classes/verifications/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/index.ts#L133-L135

Added lines #L133 - L135 were not covered by tests
case VerificationType.NewPasswordIdentity: {
return new NewPasswordIdentityVerification(libraries, queries, data);
}
Expand Down
271 changes: 271 additions & 0 deletions packages/core/src/routes/experience/classes/verifications/web-authn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { type ToZodObject } from '@logto/connector-kit';
import {
type BindWebAuthn,
bindWebAuthnGuard,
type BindWebAuthnPayload,
MfaFactor,
VerificationType,
type WebAuthnRegistrationOptions,
type WebAuthnVerificationPayload,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { type PublicKeyCredentialRequestOptionsJSON } from 'node_modules/@simplewebauthn/server/esm/deps.js';
import { z } from 'zod';

import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import {
generateWebAuthnAuthenticationOptions,
generateWebAuthnRegistrationOptions,
verifyWebAuthnAuthentication,
verifyWebAuthnRegistration,
} from '#src/routes/interaction/utils/webauthn.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import { type VerificationRecord } from './verification-record.js';

export type WebAuthnVerificationRecordData = {
id: string;
type: VerificationType.WebAuthn;
/** UserId is required for verifying or binding new TOTP */
userId: string;
verified: boolean;
/** The challenge generated for the WebAuthn registration */
registrationChallenge?: string;
/** The challenge generated for the WebAuthn authentication */
authenticationChallenge?: string;
registrationInfo?: BindWebAuthn;
};

export const webAuthnVerificationRecordDataGuard = z.object({
id: z.string(),
type: z.literal(VerificationType.WebAuthn),
userId: z.string(),
verified: z.boolean(),
registrationChallenge: z.string().optional(),
authenticationChallenge: z.string().optional(),
registrationInfo: bindWebAuthnGuard.optional(),
}) satisfies ToZodObject<WebAuthnVerificationRecordData>;

export class WebAuthnVerification implements VerificationRecord<VerificationType.WebAuthn> {
/**
* Factory method to create a new WebAuthnVerification instance
*
* @param userId The user id is required for generating and verifying WebAuthn options.
* A WebAuthnVerification instance can only be created if the interaction is identified.
*/
static create(libraries: Libraries, queries: Queries, userId: string) {
return new WebAuthnVerification(libraries, queries, {
id: generateStandardId(),
type: VerificationType.WebAuthn,
verified: false,
userId,
});
}

Check warning on line 67 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L61-L67

Added lines #L61 - L67 were not covered by tests

readonly id;
readonly type = VerificationType.WebAuthn;
readonly userId;
private verified;
private registrationChallenge?: string;
private readonly authenticationChallenge?: string;
private registrationInfo?: BindWebAuthn;

constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: WebAuthnVerificationRecordData
) {
const {
id,
userId,
verified,
registrationChallenge,
authenticationChallenge,
registrationInfo,
} = webAuthnVerificationRecordDataGuard.parse(data);

this.id = id;
this.userId = userId;
this.verified = verified;
this.registrationChallenge = registrationChallenge;
this.authenticationChallenge = authenticationChallenge;
this.registrationInfo = registrationInfo;
}

Check warning on line 97 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L78-L97

Added lines #L78 - L97 were not covered by tests

get isVerified() {
return this.verified;
}

Check warning on line 101 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L100-L101

Added lines #L100 - L101 were not covered by tests

/**
* @remarks
* This method is used to generate the WebAuthn registration options for the user.
* The WebAuthn registration options is used to register a new WebAuthn credential for the user.
*
* Refers to the {@link generateWebAuthnRegistrationOptions} function in `interaction/utils/webauthn.ts` file.
* Keep it as the single source of truth for generating the WebAuthn registration options.
* TODO: Consider relocating the function under a shared folder
*/
async generateWebAuthnRegistrationOptions(
ctx: WithLogContext
): Promise<WebAuthnRegistrationOptions> {
const { hostname } = ctx.URL;
const user = await this.findUser();

const registrationOptions = await generateWebAuthnRegistrationOptions({
user,
rpId: hostname,
});

this.registrationChallenge = registrationOptions.challenge;

return registrationOptions;
}

Check warning on line 126 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L113-L126

Added lines #L113 - L126 were not covered by tests

/**
* @remarks
* This method is used to verify the WebAuthn registration for the user.
* This method will verify the WebAuthn registration response and store the registration information in the instance.
* Refers to the {@link verifyBindWebAuthn} function in `interaction/verifications/mfa-payload-verification.ts` file.
*
* @throw {RequestError} with status 400, if no pending WebAuthn registration challenge is found.
* @throw {RequestError} with status 400, if the WebAuthn registration verification failed or the registration information is not found.
*/
async verifyWebAuthnRegistration(
ctx: WithLogContext,
payload: Omit<BindWebAuthnPayload, 'type'>
) {
const { hostname, origin } = ctx.URL;
const {
request: {
headers: { 'user-agent': userAgent = '' },
},
} = ctx;

assertThat(this.registrationChallenge, 'session.mfa.pending_info_not_found');

const { verified, registrationInfo } = await verifyWebAuthnRegistration(
payload,
this.registrationChallenge,
hostname,
origin
);

assertThat(verified, 'session.mfa.webauthn_verification_failed');
assertThat(registrationInfo, 'session.mfa.webauthn_verification_failed');

const { credentialID, credentialPublicKey, counter } = registrationInfo;

this.verified = true;

this.registrationInfo = {
type: MfaFactor.WebAuthn,
credentialId: credentialID,
publicKey: isoBase64URL.fromBuffer(credentialPublicKey),
counter,
agent: userAgent,
transports: [],
};
}

Check warning on line 172 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L138-L172

Added lines #L138 - L172 were not covered by tests

/**
* @remarks
* This method is used to generate the WebAuthn authentication options for the user.
* The WebAuthn authentication options is used to authenticate the user using existing WebAuthn credentials.
*
* Refers to the {@link generateWebAuthnAuthenticationOptions} function in `interaction/utils/webauthn.ts` file.
* Keep it as the single source of truth for generating the WebAuthn authentication options.
* TODO: Consider relocating the function under a shared folder
*
* @throws {RequestError} with status 400, if no WebAuthn credentials are found for the user.
*/
async generateWebAuthnAuthenticationOptions(
ctx: WithLogContext
): Promise<PublicKeyCredentialRequestOptionsJSON> {
const { hostname } = ctx.URL;
const { mfaVerifications } = await this.findUser();

const authenticationOptions = await generateWebAuthnAuthenticationOptions({
mfaVerifications,
rpId: hostname,
});

return authenticationOptions;
}

Check warning on line 197 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L186-L197

Added lines #L186 - L197 were not covered by tests

/**
* @remarks
* This method is used to verify the WebAuthn authentication for the user.
* Refers to the {@link verifyMfaPayloadVerification} function in `interaction/verifications/mfa-payload-verification.ts` file.
*
* @throws {RequestError} with status 400, if no pending WebAuthn authentication challenge is found.
* @throws {RequestError} with status 400, if the WebAuthn authentication verification failed.
*/
async verifyWebAuthnAuthentication(
ctx: WithLogContext,
payload: Omit<WebAuthnVerificationPayload, 'type'>
) {
const { hostname, origin } = ctx.URL;
const { mfaVerifications } = await this.findUser();

assertThat(this.authenticationChallenge, 'session.mfa.pending_info_not_found');

const { result, newCounter } = await verifyWebAuthnAuthentication({
payload,
challenge: this.authenticationChallenge,
rpId: hostname,
origin,
mfaVerifications,
});

assertThat(result, 'session.mfa.webauthn_verification_failed');

this.verified = true;

// Update the counter and last used time
const { updateUserById } = this.queries.users;
await updateUserById(this.userId, {
mfaVerifications: mfaVerifications.map((mfa) => {
if (mfa.type !== MfaFactor.WebAuthn || mfa.id !== result.id) {
return mfa;
}

return {
...mfa,
lastUsedAt: new Date().toISOString(),
...conditional(newCounter !== undefined && { counter: newCounter }),
};
}),
});
}

Check warning on line 243 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L208-L243

Added lines #L208 - L243 were not covered by tests

toJson(): WebAuthnVerificationRecordData {
const {
id,
userId,
verified,
type,
registrationChallenge,
authenticationChallenge,
registrationInfo,
} = this;

return {
id,
type,
userId,
verified,
registrationChallenge,
authenticationChallenge,
registrationInfo,
};
}

Check warning on line 265 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L246-L265

Added lines #L246 - L265 were not covered by tests

private async findUser() {
const { findUserById } = this.queries.users;
return findUserById(this.userId);
}

Check warning on line 270 in packages/core/src/routes/experience/classes/verifications/web-authn.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/web-authn.ts#L268-L270

Added lines #L268 - L270 were not covered by tests
}
2 changes: 2 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import passwordVerificationRoutes from './verification-routes/password-verificat
import socialVerificationRoutes from './verification-routes/social-verification.js';
import totpVerificationRoutes from './verification-routes/totp-verification.js';
import verificationCodeRoutes from './verification-routes/verification-code.js';
import webAuthnVerificationRoute from './verification-routes/web-authn-verification.js';

type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;

Expand Down Expand Up @@ -148,6 +149,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
totpVerificationRoutes(router, tenant);
webAuthnVerificationRoute(router, tenant);
backupCodeVerificationRoutes(router, tenant);
newPasswordIdentityVerificationRoutes(router, tenant);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ export default function totpVerificationRoutes<T extends WithLogContext>(

assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');

// TODO: Check if the MFA is enabled
// TODO: Check if the interaction is fully verified

const totpVerification = TotpVerification.create(
libraries,
queries,
Expand Down
Loading

0 comments on commit 40328e7

Please sign in to comment.