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

feat(core,schemas): bind webauthn (#4626)

This commit is contained in:
wangsijie 2023-10-16 12:00:59 +08:00 committed by GitHub
parent 9428d37a5e
commit af246ad863
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1052 additions and 101 deletions

View file

@ -42,6 +42,7 @@
"@logto/schemas": "workspace:^1.10.0",
"@logto/shared": "workspace:^3.0.0",
"@silverhand/essentials": "^2.8.4",
"@simplewebauthn/server": "^8.2.0",
"@withtyped/client": "^0.7.22",
"chalk": "^5.0.0",
"clean-deep": "^3.4.0",

View file

@ -1,6 +1,15 @@
import { MfaFactor, type BindMfa } from '@logto/schemas';
import { MfaFactor, type BindMfa, type BindWebAuthn } from '@logto/schemas';
export const mockTotpBind: BindMfa = {
type: MfaFactor.TOTP,
secret: 'secret',
};
export const mockWebAuthnBind: BindWebAuthn = {
type: MfaFactor.WebAuthn,
credentialId: 'credentialId',
publicKey: 'publicKey',
counter: 0,
agent: 'agent',
transports: [],
};

View file

@ -0,0 +1,20 @@
import { type WebAuthnRegistrationOptions } from '@logto/schemas';
export const mockWebAuthnCreationOptions: WebAuthnRegistrationOptions = {
rp: {
name: 'Logto',
id: 'logto.io',
},
user: {
id: 'id',
name: 'test-user',
displayName: 'Test User',
},
challenge: 'challenge',
pubKeyCredParams: [
{
type: 'public-key',
alg: -7,
},
],
};

View file

@ -0,0 +1,246 @@
import { InteractionEvent, MfaFactor, adminTenantId } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
import { mockWebAuthnBind } from '#src/__mocks__/mfa-verification.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
Identifier,
VerifiedRegisterInteractionResult,
VerifiedSignInInteractionResult,
} from '../types/index.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const getLogtoConnectorById = jest
.fn()
.mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } });
const { assignInteractionResults } = mockEsm('#src/libraries/session.js', () => ({
assignInteractionResults: jest.fn(),
}));
mockEsm('#src/libraries/user.js', () => ({
encryptUserPassword: jest.fn().mockResolvedValue({
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
}),
}));
mockEsm('@logto/shared', () => ({
generateStandardId: jest.fn().mockReturnValue('uid'),
}));
mockEsm('#src/utils/tenant.js', () => ({
getTenantId: () => adminTenantId,
}));
const userQueries = {
findUserById: jest.fn().mockResolvedValue({
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(),
hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
};
const { hasActiveUsers, updateUserById } = userQueries;
const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
const { generateUserId, insertUser } = userLibraries;
const submitInteraction = await pickDefault(import('./submit-interaction.js'));
const now = Date.now();
jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const tenant = new MockTenant(
undefined,
{ users: userQueries, signInExperiences: { updateDefaultSignInExperience: jest.fn() } },
{ getLogtoConnectorById },
{ users: userLibraries }
);
const ctx = {
...createContextWithRouteParameters(),
...createMockLogContext(),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: { params: {} } as Awaited<ReturnType<Provider['interactionDetails']>>,
assignInteractionHookResult: jest.fn(),
};
const profile = {
username: 'username',
password: 'password',
phone: '123456',
email: 'email@logto.io',
connectorId: 'logto',
};
const userInfo = {
id: 'foo',
name: 'foo_social',
avatar: 'avatar',
email: 'email@socail.com',
phone: '123123',
};
const identifiers: Identifier[] = [
{
key: 'social',
connectorId: 'logto',
userInfo,
},
];
const upsertProfile = {
username: 'username',
primaryPhone: '123456',
primaryEmail: 'email@logto.io',
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
identities: {
logto: { userId: userInfo.id, details: userInfo },
},
name: userInfo.name,
avatar: userInfo.avatar,
lastSignInAt: now,
};
afterEach(() => {
jest.clearAllMocks();
});
describe('register with bindMfa', () => {
it('should handle totp', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
bindMfa: { type: MfaFactor.TOTP, secret: 'secret' },
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(insertUser).toBeCalledWith(
{
id: 'uid',
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
...upsertProfile,
},
['user']
);
});
it('should handle webauthn', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
bindMfa: mockWebAuthnBind,
pendingAccountId: 'id',
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).not.toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(insertUser).toBeCalledWith(
{
id: 'id',
mfaVerifications: [
{
...mockWebAuthnBind,
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
...upsertProfile,
},
['user']
);
});
});
describe('sign in with bindMfa', () => {
it('should handle totp', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'secret',
},
};
await submitInteraction(interaction, ctx, tenant);
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
});
it('should handle webauthn', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers,
bindMfa: mockWebAuthnBind,
};
await submitInteraction(interaction, ctx, tenant);
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
mfaVerifications: [
{
id: 'uid',
createdAt: new Date(now).toISOString(),
...mockWebAuthnBind,
},
],
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
});
});
});

View file

@ -1,9 +1,4 @@
import {
InteractionEvent,
MfaFactor,
adminConsoleApplicationId,
adminTenantId,
} from '@logto/schemas';
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
@ -147,6 +142,33 @@ describe('submit action', () => {
});
});
it('register and use pendingAccountId', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
pendingAccountId: 'pending-account-id',
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).not.toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(encryptUserPassword).toBeCalledWith('password');
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(insertUser).toBeCalledWith(
{
id: 'pending-account-id',
...upsertProfile,
},
['user']
);
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'pending-account-id' },
});
});
it('register new social user', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
@ -210,35 +232,6 @@ describe('submit action', () => {
);
});
it('register with bindMfa', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
bindMfa: { type: MfaFactor.TOTP, secret: 'secret' },
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(insertUser).toBeCalledWith(
{
id: 'uid',
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
...upsertProfile,
},
['user']
);
});
it('admin user register', async () => {
hasActiveUsers.mockResolvedValueOnce(false);
const adminConsoleCtx = {
@ -308,41 +301,6 @@ describe('submit action', () => {
});
});
it('sign-in with bindMfa', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'secret',
},
};
await submitInteraction(interaction, ctx, tenant);
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
});
it('sign-in and sync new Social', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },

View file

@ -9,6 +9,7 @@ import {
adminTenantId,
InteractionEvent,
adminConsoleApplicationId,
MfaFactor,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
@ -41,12 +42,23 @@ const parseBindMfa = ({
return;
}
return {
type: bindMfa.type,
key: bindMfa.secret,
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
if (bindMfa.type === MfaFactor.TOTP) {
return {
type: MfaFactor.TOTP,
key: bindMfa.secret,
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (bindMfa.type === MfaFactor.WebAuthn) {
return {
...bindMfa,
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
}
};
const getInitialUserRoles = (
@ -76,7 +88,8 @@ export default async function submitInteraction(
const { event, profile } = interaction;
if (event === InteractionEvent.Register) {
const id = await generateUserId();
const { pendingAccountId } = interaction;
const id = pendingAccountId ?? (await generateUserId());
const userProfile = await parseUserProfile(tenantContext, interaction);
const mfaVerification = parseBindMfa(interaction);

View file

@ -1,8 +1,10 @@
import { ConnectorType } from '@logto/connector-kit';
import { demoAppApplicationId, InteractionEvent } from '@logto/schemas';
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
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 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';
@ -52,6 +54,21 @@ const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
})
);
const { generateWebAuthnRegistrationOptions } = await mockEsmWithActual(
'./utils/webauthn.js',
() => ({
generateWebAuthnRegistrationOptions: jest.fn().mockResolvedValue(mockWebAuthnCreationOptions),
})
);
const { verifyIdentifier, verifyProfile } = await mockEsmWithActual(
'./verifications/index.js',
() => ({
verifyIdentifier: jest.fn().mockResolvedValue({}),
verifyProfile: jest.fn(),
})
);
const { createLog, prependAllLogEntries } = createMockLogContext();
await mockEsmWithActual(
@ -79,6 +96,9 @@ const tenantContext = new MockTenant(
signInExperiences: {
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
},
users: {
findUserById: jest.fn().mockResolvedValue(mockUser),
},
},
{
getLogtoConnectorById: async (connectorId: string) => {
@ -94,6 +114,11 @@ const tenantContext = new MockTenant(
// @ts-expect-error
return connector as LogtoConnector;
},
},
{
users: {
generateUserId: jest.fn().mockReturnValue('generated-id'),
},
}
);
@ -197,4 +222,67 @@ describe('interaction routes', () => {
expect(response.body).toHaveProperty('secretQrCode');
});
});
describe('POST /verification/webauthn-registration', () => {
const path = `${interactionPrefix}/${verificationPath}/webauthn-registration`;
afterEach(() => {
getInteractionStorage.mockClear();
});
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: mockWebAuthnCreationOptions.challenge,
},
pendingAccountId: 'generated-id',
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.statusCode).toEqual(200);
expect(response.body).toMatchObject(mockWebAuthnCreationOptions);
});
it('should return WebAuthn options for existing user', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
});
verifyIdentifier.mockResolvedValueOnce({
event: InteractionEvent.SignIn,
});
verifyProfile.mockResolvedValueOnce({
event: InteractionEvent.SignIn,
});
const response = await sessionRequest.post(path).send();
expect(response.statusCode).toEqual(200);
expect(generateWebAuthnRegistrationOptions).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{
pendingMfa: {
type: MfaFactor.WebAuthn,
challenge: mockWebAuthnCreationOptions.challenge,
},
},
expect.anything(),
expect.anything(),
expect.anything()
);
expect(response.body).toMatchObject(mockWebAuthnCreationOptions);
});
});
});

View file

@ -1,25 +1,52 @@
import { MfaFactor, requestVerificationCodePayloadGuard } from '@logto/schemas';
import {
InteractionEvent,
MfaFactor,
requestVerificationCodePayloadGuard,
webAuthnRegistrationOptionsGuard,
} from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import qrcode from 'qrcode';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/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';
import assertThat from '#src/utils/assert-that.js';
import { parseUserProfile } from './actions/helpers.js';
import { interactionPrefix, verificationPath } from './const.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
import {
getInteractionStorage,
isRegisterInteractionResult,
isSignInInteractionResult,
storeInteractionResult,
} from './utils/interaction.js';
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 { verifyIdentifier } from './verifications/index.js';
import verifyProfile from './verifications/profile-verification.js';
export default function additionalRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
tenant: TenantContext
) {
const {
provider,
libraries: {
users: { generateUserId },
passcodes,
},
queries: {
users: { findUserById },
},
} = tenant;
// Create social authorization url interaction verification
router.post(
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
@ -63,7 +90,7 @@ export default function additionalRoutes<T extends IRouterParamContext>(
{ event, ...guard.body },
interactionDetails.jti,
createLog,
tenant.libraries.passcodes
passcodes
);
ctx.status = 204;
@ -92,7 +119,7 @@ export default function additionalRoutes<T extends IRouterParamContext>(
await storeInteractionResult(
{ pendingMfa: { type: MfaFactor.TOTP, secret } },
ctx,
tenant.provider,
provider,
true
);
@ -104,4 +131,86 @@ export default function additionalRoutes<T extends IRouterParamContext>(
return next();
}
);
router.post(
`${interactionPrefix}/${verificationPath}/webauthn-registration`,
koaGuard({
status: [200],
response: webAuthnRegistrationOptionsGuard,
}),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
// Check interaction exists
const interaction = getInteractionStorage(interactionDetails.result);
const { event } = interaction;
assertThat(
event !== InteractionEvent.ForgotPassword,
'session.not_supported_for_forgot_password'
);
createLog(`Interaction.${event}.BindMfa.WebAuthn.Create`);
// WebAuthn requires user id and name, so we need to verify and get profile first
const accountVerifiedInteraction = await verifyIdentifier(ctx, tenant, interaction);
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
if (isRegisterInteractionResult(profileVerifiedInteraction)) {
const newAccountId = await generateUserId();
const newUserProfile = await parseUserProfile(tenant, profileVerifiedInteraction);
const options = await generateWebAuthnRegistrationOptions({
rpId: EnvSet.values.endpoint.hostname,
user: {
id: newAccountId,
username: newUserProfile.username ?? newAccountId,
primaryEmail: newUserProfile.primaryEmail ?? null,
primaryPhone: newUserProfile.primaryPhone ?? null,
mfaVerifications: [],
},
});
await storeInteractionResult(
{
pendingMfa: { type: MfaFactor.WebAuthn, challenge: options.challenge },
pendingAccountId: newAccountId,
},
ctx,
provider,
true
);
ctx.body = options;
return next();
}
if (isSignInInteractionResult(profileVerifiedInteraction)) {
const { accountId } = profileVerifiedInteraction;
const { id, username, primaryEmail, primaryPhone, mfaVerifications } = await findUserById(
accountId
);
const options = await generateWebAuthnRegistrationOptions({
rpId: EnvSet.values.endpoint.hostname,
user: {
id,
username,
primaryEmail,
primaryPhone,
mfaVerifications,
},
});
await storeInteractionResult(
{
pendingMfa: { type: MfaFactor.WebAuthn, challenge: options.challenge },
},
ctx,
provider,
true
);
ctx.body = options;
return next();
}
}
);
}

View file

@ -2,6 +2,7 @@ import { InteractionEvent, bindMfaPayloadGuard, verifyMfaPayloadGuard } from '@l
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
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';
@ -35,7 +36,14 @@ export default function mfaRoutes<T extends IRouterParamContext>(
koaInteractionSie(queries),
async (ctx, next) => {
const bindMfaPayload = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
const {
signInExperience,
interactionDetails,
createLog,
request: {
headers: { 'user-agent': userAgent = '' },
},
} = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
const log = createLog(`Interaction.${interactionStorage.event}.BindMfa.Totp.Submit`);
@ -44,7 +52,12 @@ export default function mfaRoutes<T extends IRouterParamContext>(
verifyMfaSettings(bindMfaPayload.type, signInExperience);
}
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage);
const { hostname, origin } = EnvSet.values.endpoint;
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage, {
rpId: hostname,
userAgent,
origin,
});
log.append({ bindMfa, interactionStorage });

View file

@ -56,6 +56,9 @@ export const anonymousInteractionResultGuard = z.object({
pendingMfa: pendingMfaGuard.optional(),
// The verified mfa
verifiedMfa: verifyMfaResultGuard.optional(),
// The user id to be used for register, if not provided, a new one will be generated
// WebAuthn requires a user id to be provided, so we have to generate and know it before submit interaction
pendingAccountId: z.string().optional(),
});
export const forgotPasswordProfileGuard = z.object({

View file

@ -79,6 +79,7 @@ export type VerifiedRegisterInteractionResult = {
profile?: Profile;
identifiers?: Identifier[];
bindMfa?: BindMfa;
pendingAccountId?: string;
};
export type VerifiedSignInInteractionResult = {

View file

@ -17,6 +17,7 @@ import type {
VerifiedInteractionResult,
RegisterInteractionResult,
AccountVerifiedInteractionResult,
VerifiedRegisterInteractionResult,
} from '../types/index.js';
const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => {
@ -88,6 +89,11 @@ export const isForgotPasswordInteractionResult = (
): interaction is VerifiedForgotPasswordInteractionResult =>
interaction.event === InteractionEvent.ForgotPassword;
export const isRegisterInteractionResult = (
interaction: VerifiedInteractionResult
): interaction is VerifiedRegisterInteractionResult =>
interaction.event === InteractionEvent.Register;
export const isSignInInteractionResult = (
interaction: RegisterInteractionResult | AccountVerifiedInteractionResult
): interaction is AccountVerifiedInteractionResult => interaction.event === InteractionEvent.SignIn;

View file

@ -0,0 +1,68 @@
import {
type BindWebAuthnPayload,
MfaFactor,
type MfaVerificationWebAuthn,
type User,
type WebAuthnRegistrationOptions,
} from '@logto/schemas';
import {
type GenerateRegistrationOptionsOpts,
generateRegistrationOptions,
verifyRegistrationResponse,
type VerifyRegistrationResponseOpts,
} from '@simplewebauthn/server';
type GenerateWebAuthnRegistrationOptionsParameters = {
rpId: string;
user: Pick<User, 'id' | 'username' | 'primaryEmail' | 'primaryPhone' | 'mfaVerifications'>;
};
export const generateWebAuthnRegistrationOptions = async ({
rpId,
user,
}: GenerateWebAuthnRegistrationOptionsParameters): Promise<WebAuthnRegistrationOptions> => {
const options: GenerateRegistrationOptionsOpts = {
rpName: rpId,
rpID: rpId,
userID: user.id,
userName: user.username ?? user.primaryEmail ?? user.primaryPhone ?? user.id,
timeout: 60_000,
attestationType: 'none',
excludeCredentials: user.mfaVerifications
.filter(
(verification): verification is MfaVerificationWebAuthn =>
verification.type === MfaFactor.WebAuthn
)
.map(({ credentialId, transports }) => ({
id: Uint8Array.from(Buffer.from(credentialId, 'base64')),
type: 'public-key',
transports,
})),
authenticatorSelection: {
residentKey: 'discouraged',
},
// Values for COSEALG.ES256, COSEALG.RS256, Node.js don't have those enums
supportedAlgorithmIDs: [-7, -257],
};
return generateRegistrationOptions(options);
};
export const verifyWebAuthnRegistration = async (
payload: Omit<BindWebAuthnPayload, 'type'>,
challenge: string,
rpId: string,
origin: string
) => {
const options: VerifyRegistrationResponseOpts = {
response: {
...payload,
type: 'public-key',
},
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpId,
requireUserVerification: true,
};
return verifyRegistrationResponse(options);
};

View file

@ -28,6 +28,12 @@ const { bindMfaPayloadVerification, verifyMfaPayloadVerification } = await impor
'./mfa-payload-verification.js'
);
const additionalParameters = {
rpId: 'logto.io',
userAgent: 'userAgent',
origin: 'https://logto.io',
};
describe('bindMfaPayloadVerification', () => {
const baseCtx = {
...createContextWithRouteParameters(),
@ -54,7 +60,8 @@ describe('bindMfaPayloadVerification', () => {
type: MfaFactor.TOTP,
secret: 'secret',
},
}
},
additionalParameters
)
).resolves.toMatchObject({
type: MfaFactor.TOTP,
@ -66,7 +73,12 @@ describe('bindMfaPayloadVerification', () => {
it('should reject when pendingMfa is missing', async () => {
await expect(
bindMfaPayloadVerification(baseCtx, { type: MfaFactor.TOTP, code: '123456' }, interaction)
bindMfaPayloadVerification(
baseCtx,
{ type: MfaFactor.TOTP, code: '123456' },
interaction,
additionalParameters
)
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
});
@ -83,7 +95,8 @@ describe('bindMfaPayloadVerification', () => {
type: MfaFactor.TOTP,
secret: 'secret',
},
}
},
additionalParameters
)
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
});

View file

@ -9,7 +9,10 @@ import {
type MfaVerificationTotp,
type VerifyMfaPayload,
type VerifyMfaResult,
type BindWebAuthn,
type BindWebAuthnPayload,
} from '@logto/schemas';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
@ -17,6 +20,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';
const verifyBindTotp = async (
interactionStorage: AnonymousInteractionResult,
@ -27,8 +31,6 @@ const verifyBindTotp = async (
ctx.createLog(`Interaction.${event}.BindMfa.Totp.Submit`);
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
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
assertThat(pendingMfa.type === MfaFactor.TOTP, 'session.mfa.pending_info_not_found');
const { code, type } = payload;
@ -61,12 +63,69 @@ const verifyTotp = async (
return { type, id };
};
const verifyBindWebAuthn = async (
interactionStorage: AnonymousInteractionResult,
payload: BindWebAuthnPayload,
ctx: WithLogContext,
{
rpId,
userAgent,
origin,
}: {
rpId: string;
userAgent: string;
origin: string;
}
): Promise<BindWebAuthn> => {
const { event, pendingMfa } = interactionStorage;
ctx.createLog(`Interaction.${event}.BindMfa.Totp.Submit`);
assertThat(pendingMfa, 'session.mfa.pending_info_not_found');
assertThat(pendingMfa.type === MfaFactor.WebAuthn, 'session.mfa.pending_info_not_found');
const { type, ...rest } = payload;
const { challenge } = pendingMfa;
const { verified, registrationInfo } = await verifyWebAuthnRegistration(
rest,
challenge,
rpId,
origin
);
assertThat(verified, 'session.mfa.webauthn_verification_failed');
assertThat(registrationInfo, 'session.mfa.webauthn_verification_failed');
const { credentialID, credentialPublicKey, counter } = registrationInfo;
return {
type,
credentialId: isoBase64URL.fromBuffer(credentialID),
publicKey: isoBase64URL.fromBuffer(credentialPublicKey),
counter,
agent: userAgent,
transports: payload.response.transports ?? [],
};
};
export async function bindMfaPayloadVerification(
ctx: WithLogContext,
bindMfaPayload: BindMfaPayload,
interactionStorage: AnonymousInteractionResult
interactionStorage: AnonymousInteractionResult,
{
rpId,
userAgent,
origin,
}: {
rpId: string;
userAgent: string;
origin: string;
}
): Promise<BindMfa> {
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
if (bindMfaPayload.type === MfaFactor.TOTP) {
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
}
return verifyBindWebAuthn(interactionStorage, bindMfaPayload, ctx, { rpId, userAgent, origin });
}
export async function verifyMfaPayloadVerification(

View file

@ -25,8 +25,6 @@ export const verifyBindMfa = async (
const { type } = bindMfa;
// There will be more types later
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (type === MfaFactor.TOTP) {
const { accountId } = interaction;
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);

View file

@ -24,6 +24,8 @@ const session = {
'Benutzerkennung nicht gefunden. Bitte gehen Sie zurück und melden Sie sich erneut an.',
interaction_not_found:
'Interaktionssitzung nicht gefunden. Bitte gehen Sie zurück und starten Sie die Sitzung erneut.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -33,6 +35,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -21,11 +21,13 @@ const session = {
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
require_mfa_verification: 'Mfa verification is required to sign in.',
mfa_sign_in_only: 'Mfa is only available for sign-in interaction.',
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.',
},
};

View file

@ -25,6 +25,8 @@ const session = {
'Identificador de usuario no encontrado. Vuelva atrás e inicie sesión nuevamente.',
interaction_not_found:
'No se encuentra la sesión de interacción. Vuelva atrás y vuelva a iniciar la sesión.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -34,6 +36,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -26,6 +26,8 @@ const session = {
'Identifiant utilisateur introuvable. Veuillez retourner en arrière et vous connecter à nouveau.',
interaction_not_found:
"Session d'interaction introuvable. Veuillez retourner en arrière et recommencer la session.",
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -35,6 +37,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -24,6 +24,8 @@ const session = {
identifier_not_found: 'Identificativo utente non trovato. Torna indietro e accedi nuovamente.',
interaction_not_found:
'Sessione di interazione non trovata. Torna indietro e avvia la sessione nuovamente.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -33,6 +35,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -22,6 +22,8 @@ const session = {
identifier_not_found: 'ユーザーIDが見つかりません。戻って再度サインインしてください。',
interaction_not_found:
'インタラクションセッションが見つかりません。戻ってセッションを開始してください。',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -31,6 +33,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -21,6 +21,8 @@ const session = {
connector_validation_session_not_found: '연동 세션 유효성 검증을 위한 토큰을 찾을 수 없어요.',
identifier_not_found: '사용자 식별자를 찾을 수 없어요. 처음부터 다시 로그인을 시도해 주세요.',
interaction_not_found: '인터렉션 세션을 찾을 수 없어요. 처음부터 다시 세션을 시작해 주세요.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -30,6 +32,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -23,6 +23,8 @@ const session = {
'Nie znaleziono identyfikatora użytkownika. Proszę wróć i zaloguj się ponownie.',
interaction_not_found:
'Nie znaleziono sesji interakcji. Proszę wróć i rozpocznij sesję ponownie.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -32,6 +34,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -24,6 +24,8 @@ const session = {
'Identificador de usuário não encontrado. Por favor, volte e faça o login novamente.',
interaction_not_found:
'Sessão de interação não encontrada. Por favor, volte e inicie a sessão novamente.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -33,6 +35,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -26,6 +26,8 @@ const session = {
'Identificador do usuário não encontrado. Por favor, volte e faça login novamente.',
interaction_not_found:
'Sessão de interação não encontrada. Por favor, volte e inicie a sessão novamente.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -35,6 +37,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -22,6 +22,8 @@ const session = {
identifier_not_found:
'Идентификатор пользователя не найден. Вернитесь и войдите в систему снова.',
interaction_not_found: 'Сессия взаимодействия не найдена. Вернитесь и начните сессию заново.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -31,6 +33,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -23,6 +23,8 @@ const session = {
identifier_not_found: 'Kullanıcı kimliği bulunamadı. Lütfen geri gidin ve yeniden giriş yapın.',
interaction_not_found:
'Etkileşim oturumu bulunamadı. Lütfen geri gidin ve oturumu yeniden başlatın.',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -32,6 +34,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -18,6 +18,8 @@ const session = {
connector_validation_session_not_found: '找不到连接器用于验证 token 的信息。',
identifier_not_found: '找不到用户标识符。请返回并重新登录。',
interaction_not_found: '找不到交互会话。请返回并重新开始会话。',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -27,6 +29,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -18,6 +18,8 @@ const session = {
connector_validation_session_not_found: '找不到連接器用於驗證 token 的信息。',
identifier_not_found: '找不到用戶標識符。請返回並重新登錄。',
interaction_not_found: '找不到互動會話。請返回並重新開始會話。',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -27,6 +29,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -18,6 +18,8 @@ const session = {
connector_validation_session_not_found: '找不到連接器用於驗證 token 的資訊。',
identifier_not_found: '找不到使用者標識符。請返回並重新登錄。',
interaction_not_found: '找不到交互會話。請返回並重新開始會話。',
/** UNTRANSLATED */
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
mfa: {
/** UNTRANSLATED */
require_mfa_verification: 'Mfa verification is required to sign in.',
@ -27,6 +29,8 @@ const session = {
pending_info_not_found: 'Pending MFA info not found, please initiate MFA first.',
/** UNTRANSLATED */
invalid_totp_code: 'Invalid TOTP code.',
/** UNTRANSLATED */
webauthn_verification_failed: 'WebAuthn verification failed.',
},
};

View file

@ -26,11 +26,22 @@ export const mfaVerificationTotp = z.object({
export type MfaVerificationTotp = z.infer<typeof mfaVerificationTotp>;
export const webAuthnTransportGuard = z.enum([
'usb',
'nfc',
'ble',
'internal',
'cable',
'hybrid',
'smart-card',
]);
export const mfaVerificationWebAuthn = z.object({
type: z.literal(MfaFactor.WebAuthn),
...baseMfaVerification,
credentialId: z.string(),
publicKey: z.string(),
transports: webAuthnTransportGuard.array().optional(),
counter: z.number(),
agent: z.string(),
});

View file

@ -19,3 +19,4 @@ export * from './cookie.js';
export * from './dashboard.js';
export * from './domain.js';
export * from './sentinel.js';
export * from './mfa.js';

View file

@ -1,7 +1,7 @@
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod';
import { MfaFactor, jsonObjectGuard } from '../foundations/index.js';
import { MfaFactor, jsonObjectGuard, webAuthnTransportGuard } from '../foundations/index.js';
import type {
EmailVerificationCodePayload,
@ -110,7 +110,36 @@ export const bindTotpPayloadGuard = z.object({
export type BindTotpPayload = z.infer<typeof bindTotpPayloadGuard>;
export const bindMfaPayloadGuard = bindTotpPayloadGuard;
export const bindWebAuthnPayloadGuard = z.object({
type: z.literal(MfaFactor.WebAuthn),
id: z.string(),
rawId: z.string(),
response: z.object({
clientDataJSON: z.string(),
attestationObject: z.string(),
authenticatorData: z.string().optional(),
transports: webAuthnTransportGuard.array().optional(),
publicKeyAlgorithm: z.number().optional(),
publicKey: z.string().optional(),
}),
authenticatorAttachment: z.enum(['cross-platform', 'platform']).optional(),
clientExtensionResults: z.object({
appid: z.boolean().optional(),
crepProps: z
.object({
rk: z.boolean().optional(),
})
.optional(),
hmacCreateSecret: z.boolean().optional(),
}),
});
export type BindWebAuthnPayload = z.infer<typeof bindWebAuthnPayloadGuard>;
export const bindMfaPayloadGuard = z.discriminatedUnion('type', [
bindTotpPayloadGuard,
bindWebAuthnPayloadGuard,
]);
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
@ -129,9 +158,19 @@ export const pendingTotpGuard = z.object({
export type PendingTotp = z.infer<typeof pendingTotpGuard>;
export const pendingWebAuthnGuard = z.object({
type: z.literal(MfaFactor.WebAuthn),
challenge: z.string(),
});
export type PendingWebAuthn = z.infer<typeof pendingWebAuthnGuard>;
// Some information like TOTP secret should be generated in the backend
// and stored in the interaction temporarily.
export const pendingMfaGuard = pendingTotpGuard;
export const pendingMfaGuard = z.discriminatedUnion('type', [
pendingTotpGuard,
pendingWebAuthnGuard,
]);
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
@ -139,8 +178,19 @@ export const bindTotpGuard = pendingTotpGuard;
export type BindTotp = z.infer<typeof bindTotpGuard>;
export const bindWebAuthnGuard = z.object({
type: z.literal(MfaFactor.WebAuthn),
credentialId: z.string(),
publicKey: z.string(),
transports: webAuthnTransportGuard.array(),
counter: z.number(),
agent: z.string(),
});
export type BindWebAuthn = z.infer<typeof bindWebAuthnGuard>;
// The type for binding new mfa verification to a user, not always equals to the pending type.
export const bindMfaGuard = bindTotpGuard;
export const bindMfaGuard = z.discriminatedUnion('type', [bindTotpGuard, bindWebAuthnGuard]);
export type BindMfa = z.infer<typeof bindMfaGuard>;

View file

@ -0,0 +1,50 @@
import { z } from 'zod';
import { webAuthnTransportGuard } from '../foundations/jsonb-types/index.js';
export const webAuthnRegistrationOptionsGuard = z.object({
rp: z.object({
name: z.string(),
id: z.string().optional(),
}),
user: z.object({
id: z.string(),
name: z.string(),
displayName: z.string(),
}),
challenge: z.string(),
pubKeyCredParams: z.array(
z.object({
type: z.literal('public-key'),
alg: z.number(),
})
),
timeout: z.number().optional(),
excludeCredentials: z
.array(
z.object({
type: z.literal('public-key'),
id: z.string(),
transports: webAuthnTransportGuard.array().optional(),
})
)
.optional(),
authenticatorSelection: z
.object({
authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(),
requireResidentKey: z.boolean().optional(),
residentKey: z.enum(['discouraged', 'preferred', 'required']).optional(),
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
})
.optional(),
attestation: z.enum(['none', 'indirect', 'direct', 'enterprise']).optional(),
extensions: z
.object({
appid: z.string().optional(),
credProps: z.boolean().optional(),
hmacCreateSecret: z.boolean().optional(),
})
.optional(),
});
export type WebAuthnRegistrationOptions = z.infer<typeof webAuthnRegistrationOptionsGuard>;

182
pnpm-lock.yaml generated
View file

@ -3172,6 +3172,9 @@ importers:
'@silverhand/essentials':
specifier: ^2.8.4
version: 2.8.4
'@simplewebauthn/server':
specifier: ^8.2.0
version: 8.2.0
'@withtyped/client':
specifier: ^0.7.22
version: 0.7.22(zod@3.22.3)
@ -6356,6 +6359,54 @@ packages:
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
dev: true
/@cbor-extract/cbor-extract-darwin-arm64@2.1.1:
resolution: {integrity: sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-darwin-x64@2.1.1:
resolution: {integrity: sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-arm64@2.1.1:
resolution: {integrity: sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-arm@2.1.1:
resolution: {integrity: sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-linux-x64@2.1.1:
resolution: {integrity: sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@cbor-extract/cbor-extract-win32-x64@2.1.1:
resolution: {integrity: sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@changesets/apply-release-plan@6.1.4:
resolution: {integrity: sha512-FMpKF1fRlJyCZVYHr3CbinpZZ+6MwvOtWUuO8uo+svcATEoc1zRDcj23pAurJ2TZ/uVz1wFHH6K3NlACy0PLew==}
dependencies:
@ -6843,6 +6894,10 @@ packages:
'@hapi/hoek': 9.3.0
dev: true
/@hexagon/base64@1.1.28:
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
dev: false
/@humanwhocodes/config-array@0.11.10:
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'}
@ -8564,6 +8619,50 @@ packages:
nullthrows: 1.1.1
dev: true
/@peculiar/asn1-android@2.3.6:
resolution: {integrity: sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
asn1js: 3.0.5
tslib: 2.5.0
dev: false
/@peculiar/asn1-ecc@2.3.6:
resolution: {integrity: sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
asn1js: 3.0.5
tslib: 2.5.0
dev: false
/@peculiar/asn1-rsa@2.3.6:
resolution: {integrity: sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
asn1js: 3.0.5
tslib: 2.5.0
dev: false
/@peculiar/asn1-schema@2.3.6:
resolution: {integrity: sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==}
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.5
tslib: 2.5.0
dev: false
/@peculiar/asn1-x509@2.3.6:
resolution: {integrity: sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==}
dependencies:
'@peculiar/asn1-schema': 2.3.6
asn1js: 3.0.5
ipaddr.js: 2.1.0
pvtsutils: 1.3.5
tslib: 2.5.0
dev: false
/@pkgr/utils@2.3.1:
resolution: {integrity: sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@ -9033,6 +9132,27 @@ packages:
typescript: 5.0.2
dev: true
/@simplewebauthn/server@8.2.0:
resolution: {integrity: sha512-nknf7kCa5V61Kk2zn1vTuKeAlyut9aWduIcbHNQWpMCEJqH/m8cXpb+9UV42MEQRIk8JVC1GSNeEx56QVTfJHw==}
engines: {node: '>=16.0.0'}
dependencies:
'@hexagon/base64': 1.1.28
'@peculiar/asn1-android': 2.3.6
'@peculiar/asn1-ecc': 2.3.6
'@peculiar/asn1-rsa': 2.3.6
'@peculiar/asn1-schema': 2.3.6
'@peculiar/asn1-x509': 2.3.6
'@simplewebauthn/typescript-types': 8.0.0
cbor-x: 1.5.4
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
dev: false
/@simplewebauthn/typescript-types@8.0.0:
resolution: {integrity: sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==}
dev: false
/@sinclair/typebox@0.24.46:
resolution: {integrity: sha512-ng4ut1z2MCBhK/NwDVwIQp3pAUOCs/KNaW3cBxdFB2xTDrOuo1xuNmpr/9HHFhxqIvHrs1NTH3KJg6q+JSy1Kw==}
dev: true
@ -10402,6 +10522,15 @@ packages:
safer-buffer: 2.1.2
dev: false
/asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
dependencies:
pvtsutils: 1.3.5
pvutils: 1.1.3
tslib: 2.5.0
dev: false
/ast-types-flow@0.0.7:
resolution: {integrity: sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==}
dev: true
@ -10823,6 +10952,28 @@ packages:
resolution: {integrity: sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg==}
dev: true
/cbor-extract@2.1.1:
resolution: {integrity: sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==}
hasBin: true
requiresBuild: true
dependencies:
node-gyp-build-optional-packages: 5.0.3
optionalDependencies:
'@cbor-extract/cbor-extract-darwin-arm64': 2.1.1
'@cbor-extract/cbor-extract-darwin-x64': 2.1.1
'@cbor-extract/cbor-extract-linux-arm': 2.1.1
'@cbor-extract/cbor-extract-linux-arm64': 2.1.1
'@cbor-extract/cbor-extract-linux-x64': 2.1.1
'@cbor-extract/cbor-extract-win32-x64': 2.1.1
dev: false
optional: true
/cbor-x@1.5.4:
resolution: {integrity: sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==}
optionalDependencies:
cbor-extract: 2.1.1
dev: false
/ccount@1.1.0:
resolution: {integrity: sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==}
dev: true
@ -11332,7 +11483,6 @@ packages:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
dev: true
/cross-spawn@5.1.0:
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
@ -13884,6 +14034,11 @@ packages:
resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==}
dev: true
/ipaddr.js@2.1.0:
resolution: {integrity: sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==}
engines: {node: '>= 10'}
dev: false
/is-alphabetical@1.0.4:
resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
dev: true
@ -16349,7 +16504,6 @@ packages:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: true
/node-fetch@3.3.0:
resolution: {integrity: sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==}
@ -16365,6 +16519,13 @@ packages:
engines: {node: '>= 6.13.0'}
dev: false
/node-gyp-build-optional-packages@5.0.3:
resolution: {integrity: sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==}
hasBin: true
requiresBuild: true
dev: false
optional: true
/node-gyp-build-optional-packages@5.0.6:
resolution: {integrity: sha512-2ZJErHG4du9G3/8IWl/l9Bp5BBFy63rno5GVmjQijvTuUZKsl6g8RB4KH/x3NLcV5ZBb4GsXmAuTYr6dRml3Gw==}
hasBin: true
@ -17562,6 +17723,17 @@ packages:
resolution: {integrity: sha512-rLSBxJjP+4DQOgcJAx6RZHT2he2pkhQdSnofG5VWyVl6GRq/K02ISOuOLcsMOrtKDIJb8JN2zm3FFzWNbezdPw==}
dev: true
/pvtsutils@1.3.5:
resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
dependencies:
tslib: 2.6.2
dev: false
/pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
dev: false
/q@1.5.1:
resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
@ -18478,7 +18650,7 @@ packages:
/rxjs@7.8.0:
resolution: {integrity: sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==}
dependencies:
tslib: 2.4.1
tslib: 2.5.0
/safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
@ -19828,6 +20000,10 @@ packages:
/tslib@2.5.0:
resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==}
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/tsscmp@1.0.6:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}