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:
parent
718053739c
commit
32fadf6f16
26 changed files with 779 additions and 34 deletions
|
@ -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],
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
120
packages/core/src/routes/interaction/utils/webauthn.test.ts
Normal file
120
packages/core/src/routes/interaction/utils/webauthn.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue