From 36fc1a47b3310d61150818c3dd18a13a85b775ab Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 8 Jul 2024 14:17:00 +0800 Subject: [PATCH] feat(core): implement enterprise sso verification flow implement the enterprise sso verification flow --- .../classes/experience-interaction.ts | 8 +- .../enterprise-sso-verification.ts | 208 ++++++++++++++++++ .../experience/classes/verifications/index.ts | 18 +- .../verifications/social-verification.ts | 7 +- packages/core/src/routes/experience/index.ts | 2 + .../enterprise-sso-verification.ts | 103 +++++++++ .../interaction/utils/single-sign-on.ts | 2 +- .../src/client/experience/index.ts | 30 +++ .../experience/enterprise-sso-verification.ts | 41 ++++ .../enterprise-sso-verification.test.ts | 201 +++++++++++++++++ 10 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts create mode 100644 packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts create mode 100644 packages/integration-tests/src/helpers/experience/enterprise-sso-verification.ts create mode 100644 packages/integration-tests/src/tests/api/experience-api/verifications/enterprise-sso-verification.test.ts diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 35b6d79c67e..0ae537b5e14 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -59,7 +59,7 @@ export default class ExperienceInteraction { constructor( private readonly ctx: WithLogContext, private readonly tenant: TenantContext, - interactionDetails: Interaction + public interactionDetails: Interaction ) { const { libraries, queries } = tenant; @@ -125,8 +125,12 @@ export default class ExperienceInteraction { switch (verificationRecord.type) { case VerificationType.Password: case VerificationType.VerificationCode: - case VerificationType.Social: { + case VerificationType.Social: + case VerificationType.EnterpriseSso: { + // TODO: social sign-in with verified email + const { id, isSuspended } = await verificationRecord.identifyUser(); + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); // Throws an 409 error if the current session has already identified a different user diff --git a/packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts b/packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts new file mode 100644 index 00000000000..c282d76350c --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts @@ -0,0 +1,208 @@ +import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; +import { + VerificationType, + type JsonObject, + type SocialAuthorizationUrlPayload, + type SupportedSsoConnector, + type User, + type UserSsoIdentity, +} from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import { + getSsoAuthorizationUrl, + verifySsoIdentity, +} from '#src/routes/interaction/utils/single-sign-on.js'; +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { type VerificationRecord } from './verification-record.js'; + +/** The JSON data type for the EnterpriseSsoVerification record stored in the interaction storage */ +export type EnterpriseSsoVerificationRecordData = { + id: string; + connectorId: string; + type: VerificationType.EnterpriseSso; + /** + * The enterprise SSO identity returned by the connector. + */ + enterpriseSsoUserInfo?: SocialUserInfo; + issuer?: string; +}; + +export const enterPriseSsoVerificationRecordDataGuard = z.object({ + id: z.string(), + connectorId: z.string(), + type: z.literal(VerificationType.EnterpriseSso), + enterpriseSsoUserInfo: socialUserInfoGuard.optional(), + issuer: z.string().optional(), +}) satisfies ToZodObject; + +export class EnterpriseSsoVerification + implements VerificationRecord +{ + static create(libraries: Libraries, queries: Queries, connectorId: string) { + return new EnterpriseSsoVerification(libraries, queries, { + id: generateStandardId(), + connectorId, + type: VerificationType.EnterpriseSso, + }); + } + + public readonly id: string; + public readonly type = VerificationType.EnterpriseSso; + public readonly connectorId: string; + public enterpriseSsoUserInfo?: SocialUserInfo; + public issuer?: string; + + private connectorDataCache?: SupportedSsoConnector; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: EnterpriseSsoVerificationRecordData + ) { + const { id, connectorId, enterpriseSsoUserInfo, issuer } = + enterPriseSsoVerificationRecordDataGuard.parse(data); + + this.id = id; + this.connectorId = connectorId; + this.enterpriseSsoUserInfo = enterpriseSsoUserInfo; + this.issuer = issuer; + } + + /** Returns true if the enterprise SSO identity has been verified */ + get isVerified() { + return Boolean(this.enterpriseSsoUserInfo && this.issuer); + } + + async getConnectorData(connectorId: string) { + this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(connectorId); + + return this.connectorDataCache; + } + + /** + * Create the authorization URL for the enterprise SSO connector. + * + * @remarks + * Refers to thr {@link getSsoAuthorizationUrl} function in the interaction/utils/single-sign-on.ts file. + * Currently, all the intermediate connector session results are stored in the provider's interactionDetails separately, + * apart from the new verification record. + * For compatibility reasons, we keep using the old {@link getSsoAuthorizationUrl} method here as a single source of truth. + * Especially for the SAML connectors, + * SAML ACS endpoint will find the connector session result by the jti and assign it to the interaction storage. + * We will need to update the SAML ACS endpoint before move the logic to this new EnterpriseSsoVerification class. + */ + async createAuthorizationUrl( + ctx: WithLogContext, + tenantContext: TenantContext, + payload: SocialAuthorizationUrlPayload + ) { + const connectorData = await this.getConnectorData(this.connectorId); + return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload); + } + + /** + * Verify the enterprise SSO identity and store the enterprise SSO identity in the verification record. + * + * @remarks + * Refers to the {@link verifySsoIdentity} function in the interaction/utils/single-sign-on.ts file. + * For compatibility reasons, we keep using the old {@link verifySsoIdentity} method here as a single source of truth. + * See the above {@link createAuthorizationUrl} method for more details. + */ + async verify(ctx: WithLogContext, tenantContext: TenantContext, callbackData: JsonObject) { + const connectorData = await this.getConnectorData(this.connectorId); + const { issuer, userInfo } = await verifySsoIdentity( + ctx, + tenantContext, + connectorData, + callbackData + ); + + this.issuer = issuer; + this.enterpriseSsoUserInfo = userInfo; + } + + async identifyUser(): Promise { + assertThat( + this.isVerified, + new RequestError({ code: 'session.verification_failed', status: 422 }) + ); + + // TODO: sync userInfo and link sso identity + + const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo(); + + if (userSsoIdentityResult) { + return userSsoIdentityResult.user; + } + + const relatedUser = await this.findRelatedUserSsoIdentity(); + + if (relatedUser) { + return relatedUser; + } + + throw new RequestError({ code: 'user.identity_not_exist', status: 404 }); + } + + toJson(): EnterpriseSsoVerificationRecordData { + const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this; + + return { + id, + connectorId, + type, + enterpriseSsoUserInfo, + issuer, + }; + } + + private async findUserSsoIdentityByEnterpriseSsoUserInfo(): Promise< + | { + user: User; + userSsoIdentity: UserSsoIdentity; + } + | undefined + > { + const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = this.queries; + + if (!this.issuer || !this.enterpriseSsoUserInfo) { + return; + } + + const userSsoIdentity = await userSsoIdentitiesQueries.findUserSsoIdentityBySsoIdentityId( + this.issuer, + this.enterpriseSsoUserInfo.id + ); + + if (userSsoIdentity) { + const user = await usersQueries.findUserById(userSsoIdentity.userId); + return { + user, + userSsoIdentity, + }; + } + } + + /** + * Find the related user by the enterprise SSO identity's verified email. + */ + private async findRelatedUserSsoIdentity(): Promise { + const { users: usersQueries } = this.queries; + + if (!this.enterpriseSsoUserInfo?.email) { + return; + } + + const user = await usersQueries.findUserByEmail(this.enterpriseSsoUserInfo.email); + + return user ?? undefined; + } +} diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 018e4470630..29fb4aa0801 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -9,6 +9,11 @@ import { codeVerificationRecordDataGuard, type CodeVerificationRecordData, } from './code-verification.js'; +import { + EnterpriseSsoVerification, + enterPriseSsoVerificationRecordDataGuard, + type EnterpriseSsoVerificationRecordData, +} from './enterprise-sso-verification.js'; import { PasswordVerification, passwordVerificationRecordDataGuard, @@ -23,7 +28,8 @@ import { export type VerificationRecordData = | PasswordVerificationRecordData | CodeVerificationRecordData - | SocialVerificationRecordData; + | SocialVerificationRecordData + | EnterpriseSsoVerificationRecordData; /** * Union type for all verification record types @@ -33,12 +39,17 @@ export type VerificationRecordData = * This union type is used to narrow down the type of the verification record. * Used in the ExperienceInteraction class to store and manage all the verification records. With a given verification type, we can narrow down the type of the verification record. */ -export type VerificationRecord = PasswordVerification | CodeVerification | SocialVerification; +export type VerificationRecord = + | PasswordVerification + | CodeVerification + | SocialVerification + | EnterpriseSsoVerification; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, codeVerificationRecordDataGuard, socialVerificationRecordDataGuard, + enterPriseSsoVerificationRecordDataGuard, ]); /** @@ -59,5 +70,8 @@ export const buildVerificationRecord = ( case VerificationType.Social: { return new SocialVerification(libraries, queries, data); } + case VerificationType.EnterpriseSso: { + return new EnterpriseSsoVerification(libraries, queries, data); + } } }; diff --git a/packages/core/src/routes/experience/classes/verifications/social-verification.ts b/packages/core/src/routes/experience/classes/verifications/social-verification.ts index 5e29d1a5c73..ef3a8c5c55e 100644 --- a/packages/core/src/routes/experience/classes/verifications/social-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/social-verification.ts @@ -106,9 +106,6 @@ export class SocialVerification implements VerificationRecord { assertThat( this.isVerified, - new RequestError({ code: 'session.verification_failed', status: 400 }) + new RequestError({ code: 'session.verification_failed', status: 422 }) ); + // TODO: sync userInfo and link social identity + const user = await this.findUserBySocialIdentity(); if (!user) { diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 0c028599c6b..83c48301b1c 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -23,6 +23,7 @@ import { experienceRoutes } from './const.js'; import koaExperienceInteraction, { type WithExperienceInteractionContext, } from './middleware/koa-experience-interaction.js'; +import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js'; import passwordVerificationRoutes from './verification-routes/password-verification.js'; import socialVerificationRoutes from './verification-routes/social-verification.js'; import verificationCodeRoutes from './verification-routes/verification-code.js'; @@ -82,4 +83,5 @@ export default function experienceApiRoutes( passwordVerificationRoutes(router, tenant); verificationCodeRoutes(router, tenant); socialVerificationRoutes(router, tenant); + enterpriseSsoVerificationRoutes(router, tenant); } diff --git a/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts b/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts new file mode 100644 index 00000000000..05464cee4c8 --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts @@ -0,0 +1,103 @@ +import { + VerificationType, + socialAuthorizationUrlPayloadGuard, + socialVerificationCallbackPayloadGuard, +} from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +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'; +import assertThat from '#src/utils/assert-that.js'; + +import { EnterpriseSsoVerification } from '../classes/verifications/enterprise-sso-verification.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function enterpriseSsoVerificationRoutes( + router: Router>, + tenantContext: TenantContext +) { + const { libraries, queries } = tenantContext; + + router.post( + `${experienceRoutes.verification}/sso/:connectorId/authorization-uri`, + koaGuard({ + params: z.object({ + connectorId: z.string(), + }), + body: socialAuthorizationUrlPayloadGuard, + response: z.object({ + authorizationUri: z.string(), + verificationId: z.string(), + }), + status: [200, 400, 404, 500], + }), + async (ctx, next) => { + const { connectorId } = ctx.guard.params; + + const enterpriseSsoVerification = EnterpriseSsoVerification.create( + libraries, + queries, + connectorId + ); + + const authorizationUri = await enterpriseSsoVerification.createAuthorizationUrl( + ctx, + tenantContext, + ctx.guard.body + ); + + ctx.experienceInteraction.setVerificationRecord(enterpriseSsoVerification); + + await ctx.experienceInteraction.save(); + + ctx.body = { + authorizationUri, + verificationId: enterpriseSsoVerification.id, + }; + + return next(); + } + ); + + router.post( + `${experienceRoutes.verification}/sso/:connectorId/verify`, + koaGuard({ + params: z.object({ + connectorId: z.string(), + }), + body: socialVerificationCallbackPayloadGuard, + response: z.object({ + verificationId: z.string(), + }), + status: [200, 400, 404, 500], + }), + async (ctx, next) => { + const { connectorId } = ctx.params; + const { connectorData, verificationId } = ctx.guard.body; + + const enterpriseSsoVerificationRecord = + ctx.experienceInteraction.getVerificationRecordById(verificationId); + + assertThat( + enterpriseSsoVerificationRecord && + enterpriseSsoVerificationRecord.type === VerificationType.EnterpriseSso && + enterpriseSsoVerificationRecord.connectorId === connectorId, + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + + await enterpriseSsoVerificationRecord.verify(ctx, tenantContext, connectorData); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId, + }; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index c50e9fcaadf..3ca526da228 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -88,7 +88,7 @@ type SsoAuthenticationResult = { * * @returns The SSO authentication result */ -const verifySsoIdentity = async ( +export const verifySsoIdentity = async ( ctx: WithLogContext, { provider, id: tenantId }: TenantContext, connectorData: SupportedSsoConnector, diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index b01cd60f8a7..e0d44f45fc2 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -102,4 +102,34 @@ export class ExperienceClient extends MockClient { }) .json<{ verificationId: string }>(); } + + public async getEnterpriseSsoAuthorizationUri( + connectorId: string, + payload: { + redirectUri: string; + state: string; + } + ) { + return api + .post(`${experienceRoutes.verification}/sso/${connectorId}/authorization-uri`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ authorizationUri: string; verificationId: string }>(); + } + + public async verifyEnterpriseSsoAuthorization( + connectorId: string, + payload: { + verificationId: string; + connectorData: Record; + } + ) { + return api + .post(`${experienceRoutes.verification}/sso/${connectorId}/verify`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } } diff --git a/packages/integration-tests/src/helpers/experience/enterprise-sso-verification.ts b/packages/integration-tests/src/helpers/experience/enterprise-sso-verification.ts new file mode 100644 index 00000000000..8e727f7d92e --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/enterprise-sso-verification.ts @@ -0,0 +1,41 @@ +import { type ExperienceClient } from '#src/client/experience/index.js'; + +export const successFullyCreateEnterpriseSsoVerification = async ( + client: ExperienceClient, + connectorId: string, + payload: { + redirectUri: string; + state: string; + } +) => { + const { authorizationUri, verificationId } = await client.getEnterpriseSsoAuthorizationUri( + connectorId, + payload + ); + + expect(verificationId).toBeTruthy(); + expect(authorizationUri).toBeTruthy(); + + return { + verificationId, + authorizationUri, + }; +}; + +export const successFullyVerifyEnterpriseSsoAuthorization = async ( + client: ExperienceClient, + connectorId: string, + payload: { + verificationId: string; + connectorData: Record; + } +) => { + const { verificationId: verifiedVerificationId } = await client.verifyEnterpriseSsoAuthorization( + connectorId, + payload + ); + + expect(verifiedVerificationId).toBeTruthy(); + + return verifiedVerificationId; +}; diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/enterprise-sso-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/enterprise-sso-verification.test.ts new file mode 100644 index 00000000000..f1b98dfe255 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/enterprise-sso-verification.test.ts @@ -0,0 +1,201 @@ +import { ConnectorType } from '@logto/connector-kit'; + +import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { SsoConnectorApi } from '#src/api/sso-connector.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; +import { + successFullyCreateEnterpriseSsoVerification, + successFullyVerifyEnterpriseSsoAuthorization, +} from '#src/helpers/experience/enterprise-sso-verification.js'; +import { successFullyCreateSocialVerification } from '#src/helpers/experience/social-verification.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { devFeatureTest, generateUserId, randomString } from '#src/utils.js'; + +devFeatureTest.describe('enterprise sso verification', () => { + const state = 'fake_state'; + const redirectUri = 'http://localhost:3000/redirect'; + const authorizationCode = 'fake_code'; + const domain = `foo${randomString()}.com`; + const ssoConnectorApi = new SsoConnectorApi(); + const socialConnectorIdMap = new Map(); + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social]); + await ssoConnectorApi.createMockOidcConnector([domain]); + const { id: socialConnectorId } = await setSocialConnector(); + + socialConnectorIdMap.set(mockSocialConnectorId, socialConnectorId); + + // Make sure single sign on is enabled + await updateSignInExperience({ + singleSignOnEnabled: true, + }); + }); + + afterAll(async () => { + await ssoConnectorApi.cleanUp(); + await clearConnectorsByTypes([ConnectorType.Social]); + }); + + describe('getSsoAuthorizationUri', () => { + it('should throw if the state or redirectUri is empty', async () => { + const client = await initExperienceClient(); + const connectorId = ssoConnectorApi.firstConnectorId!; + + await expectRejects( + client.getSocialAuthorizationUri(connectorId, { + redirectUri, + state: '', + }), + { + code: 'session.insufficient_info', + status: 400, + } + ); + + await expectRejects( + client.getSocialAuthorizationUri(connectorId, { + redirectUri: '', + state, + }), + { + code: 'session.insufficient_info', + status: 400, + } + ); + }); + + it('should throw if the connector is not found', async () => { + const client = await initExperienceClient(); + + return expectRejects( + client.getEnterpriseSsoAuthorizationUri('invalid_connector_id', { + redirectUri, + state, + }), + { + code: 'entity.not_exists_with_id', + status: 404, + } + ); + }); + + it('should return the authorization uri', async () => { + const client = await initExperienceClient(); + const connectorId = ssoConnectorApi.firstConnectorId!; + + const { authorizationUri, verificationId } = await client.getEnterpriseSsoAuthorizationUri( + connectorId, + { + redirectUri, + state, + } + ); + + expect(verificationId).toBeTruthy(); + expect(authorizationUri).toBeTruthy(); + }); + }); + + describe('verifyEnterpriseSsoAuthorization', () => { + it('should throw if the verification record is not found', async () => { + const client = await initExperienceClient(); + const connectorId = ssoConnectorApi.firstConnectorId!; + + await successFullyCreateEnterpriseSsoVerification(client, connectorId, { + redirectUri, + state, + }); + + await expectRejects( + client.verifyEnterpriseSsoAuthorization(connectorId, { + verificationId: 'invalid_verification_id', + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw if the verification type is not enterprise sso', async () => { + const client = await initExperienceClient(); + const connectorId = socialConnectorIdMap.get(mockSocialConnectorId)!; + + const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + await expectRejects( + client.verifyEnterpriseSsoAuthorization(connectorId, { + verificationId, + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw if the connectorId does not match', async () => { + const client = await initExperienceClient(); + const connectorId = ssoConnectorApi.firstConnectorId!; + + const { verificationId } = await successFullyCreateEnterpriseSsoVerification( + client, + connectorId, + { + redirectUri, + state, + } + ); + + await expectRejects( + client.verifyEnterpriseSsoAuthorization('invalid_connector_id', { + verificationId, + connectorData: { + authorizationCode, + }, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should successfully verify the authorization', async () => { + const client = await initExperienceClient(); + const connectorId = ssoConnectorApi.firstConnectorId!; + + const { verificationId } = await successFullyCreateEnterpriseSsoVerification( + client, + connectorId, + { + redirectUri, + state, + } + ); + + // Pass the sub value as a callback connectorData to mock the SsoConnector.getUserInfo return value. + const fakeSsoIdentitySub = generateUserId(); + + await successFullyVerifyEnterpriseSsoAuthorization(client, connectorId, { + verificationId, + connectorData: { + authorizationCode, + sub: fakeSsoIdentitySub, + }, + }); + }); + }); +});