Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): implement the WebAuthn verification #6308

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@
// 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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
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 @@
| 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 @@
[VerificationType.EnterpriseSso]: EnterpriseSsoVerification;
[VerificationType.TOTP]: TotpVerification;
[VerificationType.BackupCode]: BackupCodeVerification;
[VerificationType.WebAuthn]: WebAuthnVerification;
[VerificationType.NewPasswordIdentity]: NewPasswordIdentityVerification;
}>;

Expand All @@ -89,6 +96,7 @@
enterPriseSsoVerificationRecordDataGuard,
totpVerificationRecordDataGuard,
backupCodeVerificationRecordDataGuard,
webAuthnVerificationRecordDataGuard,
newPasswordIdentityVerificationRecordDataGuard,
]);

Expand Down Expand Up @@ -122,6 +130,9 @@
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
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
Loading