0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core,schemas): verify webauthn (#4635)

* feat(core,schemas): bind webauthn

* feat(core,schemas): verify webauthn
This commit is contained in:
wangsijie 2023-10-16 17:11:25 +08:00 committed by GitHub
parent 718053739c
commit 32fadf6f16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 779 additions and 34 deletions

View file

@ -29,6 +29,17 @@ export const mockUserTotpMfaVerification = {
createdAt: new Date().toISOString(),
key: 'key',
} satisfies User['mfaVerifications'][number];
export const mockUserWebAuthnMfaVerification = {
id: 'fake_webauthn_id',
type: MfaFactor.WebAuthn,
createdAt: new Date().toISOString(),
credentialId: 'credentialId',
publicKey: 'publickKey',
counter: 0,
agent: 'agent',
} satisfies User['mfaVerifications'][number];
export const mockUserWithMfaVerifications: User = {
...mockUser,
mfaVerifications: [mockUserTotpMfaVerification],

View file

@ -1,6 +1,13 @@
import { type WebAuthnRegistrationOptions } from '@logto/schemas';
import {
type BindWebAuthnPayload,
MfaFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnRegistrationOptions,
type BindWebAuthn,
type WebAuthnVerificationPayload,
} from '@logto/schemas';
export const mockWebAuthnCreationOptions: WebAuthnRegistrationOptions = {
export const mockWebAuthnRegistrationOptions: WebAuthnRegistrationOptions = {
rp: {
name: 'Logto',
id: 'logto.io',
@ -18,3 +25,49 @@ export const mockWebAuthnCreationOptions: WebAuthnRegistrationOptions = {
},
],
};
export const mockWebAuthnAuthenticationOptions: WebAuthnAuthenticationOptions = {
challenge: 'challenge',
allowCredentials: [
{
id: 'id',
type: 'public-key',
transports: ['internal'],
},
],
userVerification: 'preferred',
timeout: 60_000,
rpId: 'logto.io',
};
export const mockBindWebAuthnPayload: BindWebAuthnPayload = {
type: MfaFactor.WebAuthn,
id: 'id',
rawId: 'id',
response: {
clientDataJSON: 'clientDataJSON',
attestationObject: 'attestationObject',
},
clientExtensionResults: {},
};
export const mockBindWebAuthn: BindWebAuthn = {
type: MfaFactor.WebAuthn,
credentialId: 'credentialId',
publicKey: 'publicKey',
transports: [],
counter: 0,
agent: 'userAgent',
};
export const mockWebAuthnVerificationPayload: WebAuthnVerificationPayload = {
type: MfaFactor.WebAuthn,
id: 'id',
rawId: 'id',
clientExtensionResults: {},
response: {
clientDataJSON: 'clientDataJSON',
authenticatorData: 'authenticatorData',
signature: 'signature',
},
};

View file

@ -4,7 +4,10 @@ import { createMockUtils } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { mockUser } from '#src/__mocks__/user.js';
import { mockWebAuthnCreationOptions } from '#src/__mocks__/webauthn.js';
import {
mockWebAuthnAuthenticationOptions,
mockWebAuthnRegistrationOptions,
} from '#src/__mocks__/webauthn.js';
import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
@ -54,12 +57,15 @@ const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
})
);
const { generateWebAuthnRegistrationOptions } = await mockEsmWithActual(
'./utils/webauthn.js',
() => ({
generateWebAuthnRegistrationOptions: jest.fn().mockResolvedValue(mockWebAuthnCreationOptions),
})
);
const { generateWebAuthnRegistrationOptions, generateWebAuthnAuthenticationOptions } =
await mockEsmWithActual('./utils/webauthn.js', () => ({
generateWebAuthnRegistrationOptions: jest
.fn()
.mockResolvedValue(mockWebAuthnRegistrationOptions),
generateWebAuthnAuthenticationOptions: jest
.fn()
.mockResolvedValue(mockWebAuthnAuthenticationOptions),
}));
const { verifyIdentifier, verifyProfile } = await mockEsmWithActual(
'./verifications/index.js',
@ -90,6 +96,7 @@ const baseProviderMock = {
client_id: demoAppApplicationId,
};
const findUserById = jest.fn().mockResolvedValue(mockUser);
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
{
@ -97,7 +104,7 @@ const tenantContext = new MockTenant(
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
},
users: {
findUserById: jest.fn().mockResolvedValue(mockUser),
findUserById,
},
},
{
@ -246,7 +253,7 @@ describe('interaction routes', () => {
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnCreationOptions.challenge,
challenge: mockWebAuthnRegistrationOptions.challenge,
},
pendingAccountId: 'generated-id',
},
@ -255,7 +262,7 @@ describe('interaction routes', () => {
expect.anything()
);
expect(response.statusCode).toEqual(200);
expect(response.body).toMatchObject(mockWebAuthnCreationOptions);
expect(response.body).toMatchObject(mockWebAuthnRegistrationOptions);
});
it('should return WebAuthn options for existing user', async () => {
@ -275,14 +282,113 @@ describe('interaction routes', () => {
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnCreationOptions.challenge,
challenge: mockWebAuthnRegistrationOptions.challenge,
},
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.body).toMatchObject(mockWebAuthnCreationOptions);
expect(response.body).toMatchObject(mockWebAuthnRegistrationOptions);
});
});
describe('POST /verification/webauthn-registration', () => {
const path = `${interactionPrefix}/${verificationPath}/webauthn-registration`;
it('should return WebAuthn options for new user', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.Register,
});
verifyIdentifier.mockResolvedValueOnce({
event: InteractionEvent.Register,
});
verifyProfile.mockResolvedValueOnce({
event: InteractionEvent.Register,
});
const response = await sessionRequest.post(path).send();
expect(generateWebAuthnRegistrationOptions).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnRegistrationOptions.challenge,
},
pendingAccountId: 'generated-id',
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.statusCode).toEqual(200);
expect(response.body).toMatchObject(mockWebAuthnRegistrationOptions);
});
it('should return WebAuthn options for existing user', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
});
verifyIdentifier.mockResolvedValueOnce({
event: InteractionEvent.SignIn,
});
verifyProfile.mockResolvedValueOnce({
event: InteractionEvent.SignIn,
});
findUserById.mockResolvedValueOnce(mockUser);
const response = await sessionRequest.post(path).send();
expect(response.statusCode).toEqual(200);
expect(generateWebAuthnRegistrationOptions).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnRegistrationOptions.challenge,
},
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.body).toMatchObject(mockWebAuthnRegistrationOptions);
});
});
describe('POST /verification/webauthn-authentication', () => {
const path = `${interactionPrefix}/${verificationPath}/webauthn-authentication`;
afterEach(() => {
getInteractionStorage.mockClear();
});
it('should throw for non authenticated interaction', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
});
const response = await sessionRequest.post(path).send();
expect(response.statusCode).toEqual(400);
});
it('should return WebAuthn options for existing user', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
accountId: 'accountId',
});
findUserById.mockResolvedValueOnce(mockUser);
const response = await sessionRequest.post(path).send();
expect(response.statusCode).toEqual(200);
expect(generateWebAuthnAuthenticationOptions).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnRegistrationOptions.challenge,
},
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.body).toMatchObject(mockWebAuthnAuthenticationOptions);
});
});
});

View file

@ -3,6 +3,7 @@ import {
MfaFactor,
requestVerificationCodePayloadGuard,
webAuthnRegistrationOptionsGuard,
webAuthnAuthenticationOptionsGuard,
} from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
@ -10,6 +11,7 @@ import qrcode from 'qrcode';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
@ -28,7 +30,10 @@ import {
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
import { generateTotpSecret } from './utils/totp-validation.js';
import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js';
import { generateWebAuthnRegistrationOptions } from './utils/webauthn.js';
import {
generateWebAuthnAuthenticationOptions,
generateWebAuthnRegistrationOptions,
} from './utils/webauthn.js';
import { verifyIdentifier } from './verifications/index.js';
import verifyProfile from './verifications/profile-verification.js';
@ -213,4 +218,44 @@ export default function additionalRoutes<T extends IRouterParamContext>(
}
}
);
router.post(
`${interactionPrefix}/${verificationPath}/webauthn-authentication`,
koaGuard({
status: [200],
response: webAuthnAuthenticationOptionsGuard,
}),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
// Check interaction exists
const interaction = getInteractionStorage(interactionDetails.result);
const { event, accountId } = interaction;
assertThat(
event === InteractionEvent.SignIn && accountId,
new RequestError({
code: 'session.mfa.mfa_sign_in_only',
})
);
createLog(`Interaction.${event}.Mfa.WebAuthn.Create`);
const { mfaVerifications } = await findUserById(accountId);
const options = await generateWebAuthnAuthenticationOptions({
rpId: EnvSet.values.endpoint.hostname,
mfaVerifications,
});
await storeInteractionResult(
{
pendingMfa: { type: MfaFactor.WebAuthn, challenge: options.challenge },
},
ctx,
provider,
true
);
ctx.body = options;
return next();
}
);
}

View file

@ -98,10 +98,12 @@ export default function mfaRoutes<T extends IRouterParamContext>(
})
);
const { hostname, origin } = EnvSet.values.endpoint;
const verifiedMfa = await verifyMfaPayloadVerification(
tenant,
accountId,
verifyMfaPayloadGuard
verifyMfaPayloadGuard,
interactionStorage,
{ accountId, rpId: hostname, origin }
);
await storeInteractionResult({ verifiedMfa }, ctx, provider, true);

View file

@ -0,0 +1,120 @@
import { MfaFactor } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import {
mockUser,
mockUserTotpMfaVerification,
mockUserWebAuthnMfaVerification,
} from '#src/__mocks__/user.js';
import {
mockBindWebAuthnPayload,
mockWebAuthnAuthenticationOptions,
mockWebAuthnRegistrationOptions,
mockWebAuthnVerificationPayload,
} from '#src/__mocks__/webauthn.js';
import RequestError from '#src/errors/RequestError/index.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = await mockEsmWithActual('@simplewebauthn/server', () => ({
generateRegistrationOptions: jest.fn().mockResolvedValue(mockWebAuthnRegistrationOptions),
verifyRegistrationResponse: jest.fn().mockResolvedValue({ verified: true }),
generateAuthenticationOptions: jest.fn().mockResolvedValue(mockWebAuthnAuthenticationOptions),
verifyAuthenticationResponse: jest
.fn()
.mockResolvedValue({ verified: true, authenticationInfo: { newCounter: 1 } }),
}));
const {
generateWebAuthnRegistrationOptions,
verifyWebAuthnRegistration,
generateWebAuthnAuthenticationOptions,
verifyWebAuthnAuthentication,
} = await import('./webauthn.js');
const rpId = 'logto.io';
const origin = 'https://logto.io';
describe('generateWebAuthnRegistrationOptions', () => {
it('should generate registration options', async () => {
await expect(
generateWebAuthnRegistrationOptions({ rpId, user: mockUser })
).resolves.toMatchObject(mockWebAuthnRegistrationOptions);
expect(generateRegistrationOptions).toHaveBeenCalled();
});
});
describe('verifyWebAuthnRegistration', () => {
it('should verify registration response', async () => {
await expect(
verifyWebAuthnRegistration(mockBindWebAuthnPayload, 'challenge', rpId, origin)
).resolves.toHaveProperty('verified', true);
expect(verifyRegistrationResponse).toHaveBeenCalled();
});
});
describe('generateWebAuthnAuthenticationOptions', () => {
it('should generate authentication options', async () => {
await expect(
generateWebAuthnAuthenticationOptions({
rpId,
mfaVerifications: [mockUserWebAuthnMfaVerification],
})
).resolves.toMatchObject(mockWebAuthnAuthenticationOptions);
expect(generateAuthenticationOptions).toHaveBeenCalled();
});
it('should throw when user webauthn verification can not be found', async () => {
await expect(
generateWebAuthnAuthenticationOptions({
rpId,
mfaVerifications: [mockUserTotpMfaVerification],
})
).rejects.toMatchError(new RequestError('session.mfa.webauthn_verification_not_found'));
});
});
describe('verifyWebAuthnAuthentication', () => {
it('should verify authentication response', async () => {
await expect(
verifyWebAuthnAuthentication({
payload: {
...mockWebAuthnVerificationPayload,
id: mockUserWebAuthnMfaVerification.credentialId,
},
challenge: 'challenge',
rpId,
origin,
mfaVerifications: [mockUserWebAuthnMfaVerification],
})
).resolves.toMatchObject({
result: { type: MfaFactor.WebAuthn, id: mockUserWebAuthnMfaVerification.id },
newCounter: 1,
});
expect(verifyAuthenticationResponse).toHaveBeenCalled();
});
it('should return false result when the corresponding webauthn verification can not be found', async () => {
await expect(
verifyWebAuthnAuthentication({
payload: {
...mockWebAuthnVerificationPayload,
id: 'not_found',
},
challenge: 'challenge',
rpId,
origin,
mfaVerifications: [mockUserWebAuthnMfaVerification],
})
).resolves.toMatchObject({
result: false,
});
});
});

View file

@ -4,13 +4,23 @@ import {
type MfaVerificationWebAuthn,
type User,
type WebAuthnRegistrationOptions,
type MfaVerifications,
type WebAuthnVerificationPayload,
type VerifyMfaResult,
} from '@logto/schemas';
import {
type GenerateRegistrationOptionsOpts,
generateRegistrationOptions,
verifyRegistrationResponse,
type VerifyRegistrationResponseOpts,
type GenerateAuthenticationOptionsOpts,
generateAuthenticationOptions,
type VerifyAuthenticationResponseOpts,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import RequestError from '#src/errors/RequestError/index.js';
type GenerateWebAuthnRegistrationOptionsParameters = {
rpId: string;
@ -66,3 +76,98 @@ export const verifyWebAuthnRegistration = async (
};
return verifyRegistrationResponse(options);
};
export const generateWebAuthnAuthenticationOptions = async ({
rpId,
mfaVerifications,
}: {
rpId: string;
mfaVerifications: MfaVerifications;
}) => {
const webAuthnVerifications = mfaVerifications.filter(
(verification): verification is MfaVerificationWebAuthn =>
verification.type === MfaFactor.WebAuthn
);
if (webAuthnVerifications.length === 0) {
throw new RequestError('session.mfa.webauthn_verification_not_found');
}
const options: GenerateAuthenticationOptionsOpts = {
timeout: 60_000,
allowCredentials: webAuthnVerifications.map(({ credentialId, transports }) => ({
id: isoBase64URL.toBuffer(credentialId),
type: 'public-key',
transports,
})),
userVerification: 'required',
rpID: rpId,
};
return generateAuthenticationOptions(options);
};
type VerifyWebAuthnAuthenticationParameters = {
payload: Omit<WebAuthnVerificationPayload, 'type'>;
challenge: string;
rpId: string;
origin: string;
mfaVerifications: MfaVerifications;
};
export const verifyWebAuthnAuthentication = async ({
payload,
challenge,
rpId,
origin,
mfaVerifications,
}: VerifyWebAuthnAuthenticationParameters): Promise<{
result: false | VerifyMfaResult;
newCounter?: number;
}> => {
const webAuthnVerifications = mfaVerifications.filter(
(verification): verification is MfaVerificationWebAuthn =>
verification.type === MfaFactor.WebAuthn
);
const verification = webAuthnVerifications.find(
({ credentialId }) => credentialId === payload.id
);
if (!verification) {
return { result: false };
}
const { publicKey, credentialId, counter, transports, id } = verification;
const options: VerifyAuthenticationResponseOpts = {
response: {
...payload,
type: 'public-key',
},
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialPublicKey: isoBase64URL.toBuffer(publicKey),
credentialID: isoBase64URL.toBuffer(credentialId),
counter,
transports,
},
requireUserVerification: true,
};
try {
const { verified, authenticationInfo } = await verifyAuthenticationResponse(options);
if (!verified) {
return { result: false };
}
return {
result: {
type: MfaFactor.WebAuthn,
id,
},
newCounter: authenticationInfo.newCounter,
};
} catch {
return { result: false };
}
};

View file

@ -2,6 +2,12 @@ import { InteractionEvent, MfaFactor } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
import { mockUserWebAuthnMfaVerification } from '#src/__mocks__/user.js';
import {
mockBindWebAuthn,
mockBindWebAuthnPayload,
mockWebAuthnVerificationPayload,
} from '#src/__mocks__/webauthn.js';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { MockTenant } from '#src/test-utils/tenant.js';
@ -13,10 +19,12 @@ const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const findUserById = jest.fn();
const updateUserById = jest.fn();
const tenantContext = new MockTenant(undefined, {
users: {
findUserById,
updateUserById,
},
});
@ -24,6 +32,25 @@ const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
validateTotpToken: jest.fn().mockReturnValue(true),
}));
const { verifyWebAuthnAuthentication, verifyWebAuthnRegistration } = mockEsm(
'../utils/webauthn.js',
() => ({
verifyWebAuthnAuthentication: jest.fn(),
verifyWebAuthnRegistration: jest.fn().mockResolvedValue({
verified: true,
registrationInfo: {
credentialID: 'credentialId',
credentialPublicKey: 'publicKey',
counter: 0,
},
}),
})
);
mockEsm('@simplewebauthn/server/helpers', () => ({
isoBase64URL: { fromBuffer: jest.fn((value) => value) },
}));
const { bindMfaPayloadVerification, verifyMfaPayloadVerification } = await import(
'./mfa-payload-verification.js'
);
@ -101,6 +128,59 @@ describe('bindMfaPayloadVerification', () => {
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
});
});
describe('webauthn', () => {
it('should return result of BindMfa', async () => {
await expect(
bindMfaPayloadVerification(
baseCtx,
mockBindWebAuthnPayload,
{
...interaction,
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: 'challenge',
},
},
additionalParameters
)
).resolves.toMatchObject(mockBindWebAuthn);
expect(verifyWebAuthnRegistration).toHaveBeenCalled();
});
it('should reject when pendingMfa is missing', async () => {
await expect(
bindMfaPayloadVerification(
baseCtx,
mockBindWebAuthnPayload,
interaction,
additionalParameters
)
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
});
it('should reject when webauthn faield', async () => {
verifyWebAuthnRegistration.mockResolvedValueOnce({
verified: false,
});
await expect(
bindMfaPayloadVerification(
baseCtx,
mockBindWebAuthnPayload,
{
...interaction,
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: 'challenge',
},
},
additionalParameters
)
).rejects.toEqual(new RequestError('session.mfa.webauthn_verification_failed'));
});
});
});
describe('verifyMfaPayloadVerification', () => {
@ -111,10 +191,15 @@ describe('verifyMfaPayloadVerification', () => {
});
await expect(
verifyMfaPayloadVerification(tenantContext, 'accountId', {
type: MfaFactor.TOTP,
code: '123456',
})
verifyMfaPayloadVerification(
tenantContext,
{
type: MfaFactor.TOTP,
code: '123456',
},
{ event: InteractionEvent.SignIn },
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).resolves.toMatchObject({
type: MfaFactor.TOTP,
id: 'id',
@ -129,10 +214,15 @@ describe('verifyMfaPayloadVerification', () => {
mfaVerifications: [],
});
await expect(
verifyMfaPayloadVerification(tenantContext, 'accountId', {
type: MfaFactor.TOTP,
code: '123456',
})
verifyMfaPayloadVerification(
tenantContext,
{
type: MfaFactor.TOTP,
code: '123456',
},
{ event: InteractionEvent.SignIn },
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
});
@ -143,11 +233,80 @@ describe('verifyMfaPayloadVerification', () => {
validateTotpToken.mockReturnValueOnce(false);
await expect(
verifyMfaPayloadVerification(tenantContext, 'accountId', {
type: MfaFactor.TOTP,
code: '123456',
})
verifyMfaPayloadVerification(
tenantContext,
{
type: MfaFactor.TOTP,
code: '123456',
},
{ event: InteractionEvent.SignIn },
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
});
});
describe('webauthn', () => {
it('should return result of VerifyMfaResult and update newCounter', async () => {
findUserById.mockResolvedValueOnce({
mfaVerifications: [mockUserWebAuthnMfaVerification],
});
const result = { type: MfaFactor.WebAuthn, id: 'id' };
verifyWebAuthnAuthentication.mockResolvedValueOnce({
result,
newCounter: 1,
});
await expect(
verifyMfaPayloadVerification(
tenantContext,
mockWebAuthnVerificationPayload,
{
event: InteractionEvent.SignIn,
pendingMfa: { type: MfaFactor.WebAuthn, challenge: 'challenge' },
},
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).resolves.toMatchObject(result);
expect(updateUserById).toHaveBeenCalledWith('accountId', {
mfaVerifications: [{ ...mockUserWebAuthnMfaVerification, counter: 1 }],
});
});
it('should reject when pendingMfa can not be found', async () => {
findUserById.mockResolvedValueOnce({
mfaVerifications: [mockUserWebAuthnMfaVerification],
});
await expect(
verifyMfaPayloadVerification(
tenantContext,
mockWebAuthnVerificationPayload,
{
event: InteractionEvent.SignIn,
},
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
});
it('should reject when webauthn result is false', async () => {
findUserById.mockResolvedValueOnce({
mfaVerifications: [mockUserWebAuthnMfaVerification],
});
verifyWebAuthnAuthentication.mockReturnValueOnce({ result: false });
await expect(
verifyMfaPayloadVerification(
tenantContext,
mockWebAuthnVerificationPayload,
{
event: InteractionEvent.SignIn,
pendingMfa: { type: MfaFactor.WebAuthn, challenge: 'challenge' },
},
{ rpId: 'rpId', origin: 'origin', accountId: 'accountId' }
)
).rejects.toEqual(new RequestError('session.mfa.webauthn_verification_failed'));
});
});
});

View file

@ -11,6 +11,8 @@ import {
type VerifyMfaResult,
type BindWebAuthn,
type BindWebAuthnPayload,
type MfaVerifications,
type WebAuthnVerificationPayload,
} from '@logto/schemas';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
@ -20,7 +22,7 @@ import assertThat from '#src/utils/assert-that.js';
import type { AnonymousInteractionResult } from '../types/index.js';
import { validateTotpToken } from '../utils/totp-validation.js';
import { verifyWebAuthnRegistration } from '../utils/webauthn.js';
import { verifyWebAuthnAuthentication, verifyWebAuthnRegistration } from '../utils/webauthn.js';
const verifyBindTotp = async (
interactionStorage: AnonymousInteractionResult,
@ -128,12 +130,79 @@ export async function bindMfaPayloadVerification(
return verifyBindWebAuthn(interactionStorage, bindMfaPayload, ctx, { rpId, userAgent, origin });
}
async function verifyWebAuthn(
interactionStorage: AnonymousInteractionResult,
mfaVerifications: MfaVerifications,
{ rpId, origin, payload }: { rpId: string; origin: string; payload: WebAuthnVerificationPayload }
): Promise<{ result: VerifyMfaResult; newCounter?: number }> {
const { pendingMfa } = interactionStorage;
assertThat(pendingMfa, 'session.mfa.pending_info_not_found');
// Will add more type, disable the rule for now, this can be a reminder when adding new type
assertThat(pendingMfa.type === MfaFactor.WebAuthn, 'session.mfa.pending_info_not_found');
const { result, newCounter } = await verifyWebAuthnAuthentication({
payload,
challenge: pendingMfa.challenge,
rpId,
origin,
mfaVerifications,
});
assertThat(result, 'session.mfa.webauthn_verification_failed');
return {
result,
newCounter,
};
}
export async function verifyMfaPayloadVerification(
tenant: TenantContext,
accountId: string,
verifyMfaPayload: VerifyMfaPayload
verifyMfaPayload: VerifyMfaPayload,
interactionStorage: AnonymousInteractionResult,
{
rpId,
origin,
accountId,
}: {
rpId: string;
origin: string;
accountId: string;
}
): Promise<VerifyMfaResult> {
const user = await tenant.queries.users.findUserById(accountId);
return verifyTotp(user.mfaVerifications, verifyMfaPayload);
if (verifyMfaPayload.type === MfaFactor.TOTP) {
return verifyTotp(user.mfaVerifications, verifyMfaPayload);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (verifyMfaPayload.type === MfaFactor.WebAuthn) {
const { result, newCounter } = await verifyWebAuthn(interactionStorage, user.mfaVerifications, {
payload: verifyMfaPayload,
rpId,
origin,
});
if (newCounter !== undefined) {
// Update the authenticator's counter in the DB to the newest count in the authentication
await tenant.queries.users.updateUserById(accountId, {
mfaVerifications: user.mfaVerifications.map((mfa) => {
if (mfa.type !== MfaFactor.WebAuthn) {
return mfa;
}
return {
...mfa,
counter: newCounter,
};
}),
});
}
return result;
}
throw new Error('Unsupported MFA type');
}

View file

@ -37,6 +37,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -28,6 +28,7 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
invalid_totp_code: 'Invalid TOTP code.',
webauthn_verification_failed: 'WebAuthn verification failed.',
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -38,6 +38,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -39,6 +39,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -37,6 +37,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -35,6 +35,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -34,6 +34,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -36,6 +36,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -37,6 +37,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -39,6 +39,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -35,6 +35,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -36,6 +36,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -31,6 +31,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -31,6 +31,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -31,6 +31,8 @@ const session = {
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
/** UNTRANSLATED */
webauthn_verification_not_found: 'WebAuthn verification not found.',
},
};

View file

@ -114,6 +114,11 @@ export const bindWebAuthnPayloadGuard = z.object({
type: z.literal(MfaFactor.WebAuthn),
id: z.string(),
rawId: z.string(),
/**
* The response from WebAuthn API
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential}
*/
response: z.object({
clientDataJSON: z.string(),
attestationObject: z.string(),
@ -147,7 +152,23 @@ export const totpVerificationPayloadGuard = bindTotpPayloadGuard;
export type TotpVerificationPayload = z.infer<typeof totpVerificationPayloadGuard>;
export const verifyMfaPayloadGuard = totpVerificationPayloadGuard;
export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard
.omit({ response: true })
.extend({
response: z.object({
clientDataJSON: z.string(),
authenticatorData: z.string(),
signature: z.string(),
userHandle: z.string().optional(),
}),
});
export type WebAuthnVerificationPayload = z.infer<typeof webAuthnVerificationPayloadGuard>;
export const verifyMfaPayloadGuard = z.discriminatedUnion('type', [
totpVerificationPayloadGuard,
webAuthnVerificationPayloadGuard,
]);
export type VerifyMfaPayload = z.infer<typeof verifyMfaPayloadGuard>;

View file

@ -48,3 +48,28 @@ export const webAuthnRegistrationOptionsGuard = z.object({
});
export type WebAuthnRegistrationOptions = z.infer<typeof webAuthnRegistrationOptionsGuard>;
export const webAuthnAuthenticationOptionsGuard = z.object({
challenge: z.string(),
timeout: z.number().optional(),
rpId: z.string().optional(),
allowCredentials: z
.array(
z.object({
type: z.literal('public-key'),
id: z.string(),
transports: webAuthnTransportGuard.array().optional(),
})
)
.optional(),
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
extensions: z
.object({
appid: z.string().optional(),
credProps: z.boolean().optional(),
hmacCreateSecret: z.boolean().optional(),
})
.optional(),
});
export type WebAuthnAuthenticationOptions = z.infer<typeof webAuthnAuthenticationOptionsGuard>;