diff --git a/packages/core/src/queries/user-sso-identities.ts b/packages/core/src/queries/user-sso-identities.ts index 531a6b528..53020fd4e 100644 --- a/packages/core/src/queries/user-sso-identities.ts +++ b/packages/core/src/queries/user-sso-identities.ts @@ -10,6 +10,8 @@ import { sql, type CommonQueryMethods } from '@silverhand/slonik'; import SchemaQueries from '#src/utils/SchemaQueries.js'; import { manyRows } from '#src/utils/sql.js'; +import { buildUpdateWhereWithPool } from '../database/update-where.js'; + export default class UserSsoIdentityQueries extends SchemaQueries< UserSsoIdentityKeys, CreateUserSsoIdentity, @@ -40,4 +42,16 @@ export default class UserSsoIdentityQueries extends SchemaQueries< `) ); } + + async updateUserSsoIdentityDetailByIdentityId( + issuer: string, + identityId: string, + detail: UserSsoIdentity['detail'] + ) { + return buildUpdateWhereWithPool(this.pool)(this.schema, true)({ + set: { detail }, + where: { issuer, identityId }, + jsonbMode: 'replace', + }); + } } diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index eb4adfd34..3b37c8116 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -376,9 +376,10 @@ export default class ExperienceInteraction { * @throws {RequestError} with 422 if the profile data is not unique across users * @throws {RequestError} with 422 if the required profile fields are missing **/ + // eslint-disable-next-line complexity public async submit() { const { - queries: { users: userQueries }, + queries: { users: userQueries, userSsoIdentities: userSsoIdentityQueries }, } = this.tenant; // Identified @@ -425,7 +426,8 @@ export default class ExperienceInteraction { await this.mfa.assertUserMandatoryMfaFulfilled(); } - const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.profile.data; + const { socialIdentity, enterpriseSsoIdentity, syncedEnterpriseSsoIdentity, ...rest } = + this.profile.data; const { mfaSkipped, mfaVerifications } = this.mfa.toUserMfaVerifications(); // Update user profile @@ -457,6 +459,16 @@ export default class ExperienceInteraction { lastSignInAt: Date.now(), }); + // Sync SSO identity + if (syncedEnterpriseSsoIdentity) { + const { identityId, issuer, detail } = syncedEnterpriseSsoIdentity; + await userSsoIdentityQueries.updateUserSsoIdentityDetailByIdentityId( + issuer, + identityId, + detail + ); + } + if (enterpriseSsoIdentity) { await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity); } diff --git a/packages/core/src/routes/experience/classes/helpers.ts b/packages/core/src/routes/experience/classes/helpers.ts index 8dd796243..5a32488c3 100644 --- a/packages/core/src/routes/experience/classes/helpers.ts +++ b/packages/core/src/routes/experience/classes/helpers.ts @@ -68,7 +68,7 @@ export const identifyUserByVerificationRecord = async ( */ syncedProfile?: Pick< InteractionProfile, - 'enterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name' + 'enterpriseSsoIdentity' | 'syncedEnterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name' >; }> => { // Check verification record can be used to identify a user using the `identifyUser` method. @@ -99,7 +99,12 @@ export const identifyUserByVerificationRecord = async ( case VerificationType.EnterpriseSso: { try { const user = await verificationRecord.identifyUser(); - const syncedProfile = await verificationRecord.toSyncedProfile(); + const { enterpriseSsoIdentity } = verificationRecord.toUserProfile(); + // Sync the enterprise SSO identity details + const syncedProfile = { + syncedEnterpriseSsoIdentity: enterpriseSsoIdentity, + ...(await verificationRecord.toSyncedProfile()), + }; return { user, syncedProfile }; } catch (error: unknown) { // Auto fallback to identify the related user if the user does not exist for enterprise SSO. diff --git a/packages/core/src/routes/experience/classes/libraries/provision-library.ts b/packages/core/src/routes/experience/classes/libraries/provision-library.ts index 425f76482..3fa8195d0 100644 --- a/packages/core/src/routes/experience/classes/libraries/provision-library.ts +++ b/packages/core/src/routes/experience/classes/libraries/provision-library.ts @@ -58,7 +58,7 @@ export class ProvisionLibrary { queries: { userSsoIdentities: userSsoIdentitiesQueries }, } = this.tenantContext; - const { socialIdentity, enterpriseSsoIdentity, ...rest } = profile; + const { socialIdentity, enterpriseSsoIdentity, syncedEnterpriseSsoIdentity, ...rest } = profile; const { isCreatingFirstAdminUser, initialUserRoles, customData } = await this.getUserProvisionContext(profile); 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 index 5bf28a730..dcfbf4b74 100644 --- a/packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts @@ -1,4 +1,4 @@ -import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; +import { type ToZodObject } from '@logto/connector-kit'; import { VerificationType, type JsonObject, @@ -17,10 +17,12 @@ import { getSsoAuthorizationUrl, verifySsoIdentity, } from '#src/routes/interaction/utils/single-sign-on.js'; +import { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from '#src/sso/types/saml.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 { safeParseUnknownJson } from '#src/utils/json.js'; import type { InteractionProfile } from '../../types.js'; @@ -34,7 +36,7 @@ export type EnterpriseSsoVerificationRecordData = { /** * The enterprise SSO identity returned by the connector. */ - enterpriseSsoUserInfo?: SocialUserInfo; + enterpriseSsoUserInfo?: ExtendedSocialUserInfo; issuer?: string; }; @@ -42,7 +44,7 @@ export const enterPriseSsoVerificationRecordDataGuard = z.object({ id: z.string(), connectorId: z.string(), type: z.literal(VerificationType.EnterpriseSso), - enterpriseSsoUserInfo: socialUserInfoGuard.optional(), + enterpriseSsoUserInfo: extendedSocialUserInfoGuard.optional(), issuer: z.string().optional(), }) satisfies ToZodObject; @@ -60,7 +62,7 @@ export class EnterpriseSsoVerification public readonly id: string; public readonly type = VerificationType.EnterpriseSso; public readonly connectorId: string; - public enterpriseSsoUserInfo?: SocialUserInfo; + public enterpriseSsoUserInfo?: ExtendedSocialUserInfo; public issuer?: string; private connectorDataCache?: SupportedSsoConnector; @@ -135,8 +137,7 @@ export class EnterpriseSsoVerification } /** - * Identify the user by the enterprise SSO identity. - * If the user is not found, find the related user by the enterprise SSO identity and return the related user. + * Identify the user by the enterprise SSO identity and sync the user SSO identity. */ async identifyUser(): Promise { assertThat( @@ -144,8 +145,6 @@ export class EnterpriseSsoVerification new RequestError({ code: 'session.verification_failed', status: 400 }) ); - // TODO: sync userInfo and link sso identity - const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo(); if (userSsoIdentityResult) { @@ -171,7 +170,7 @@ export class EnterpriseSsoVerification } /** - * Returns the use SSO identity as a new user profile. + * Returns the user SSO identity as a new user profile. */ toUserProfile(): Required> { assertThat( @@ -184,7 +183,7 @@ export class EnterpriseSsoVerification issuer: this.issuer, ssoConnectorId: this.connectorId, identityId: this.enterpriseSsoUserInfo.id, - detail: this.enterpriseSsoUserInfo, + detail: safeParseUnknownJson(this.enterpriseSsoUserInfo), }, }; } diff --git a/packages/core/src/routes/experience/types.ts b/packages/core/src/routes/experience/types.ts index bf0685a7d..eb9f1e340 100644 --- a/packages/core/src/routes/experience/types.ts +++ b/packages/core/src/routes/experience/types.ts @@ -28,6 +28,8 @@ export type InteractionProfile = { UserSsoIdentity, 'identityId' | 'ssoConnectorId' | 'issuer' | 'detail' >; + // Syncing the existing enterprise SSO identity detail + syncedEnterpriseSsoIdentity?: Pick; } & Pick< CreateUser, | 'avatar' @@ -64,6 +66,13 @@ export const interactionProfileGuard = Users.createGuard detail: true, }) .optional(), + syncedEnterpriseSsoIdentity: UserSsoIdentities.guard + .pick({ + identityId: true, + issuer: true, + detail: true, + }) + .optional(), }) satisfies ToZodObject; /** 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 07b480e1f..86a2fbda4 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- will migrate this file to the latest experience APIs */ -import { ConnectorError, type SocialUserInfo } from '@logto/connector-kit'; +import { ConnectorError } from '@logto/connector-kit'; import { validateRedirectUrl } from '@logto/core-kit'; import { InteractionEvent, @@ -17,9 +17,11 @@ import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import SamlConnector from '#src/sso/SamlConnector/index.js'; import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js'; +import { type ExtendedSocialUserInfo } from '#src/sso/types/saml.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 { safeParseUnknownJson } from '#src/utils/json.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; @@ -128,7 +130,7 @@ export const getSsoAuthorizationUrl = async ( type SsoAuthenticationResult = { /** The issuer of the SSO provider, we need to store this in the user SSO identity to identify the provider. */ issuer: string; - userInfo: SocialUserInfo; + userInfo: ExtendedSocialUserInfo; }; /** @@ -268,7 +270,7 @@ const signInWithSsoAuthentication = async ( // Update the user's SSO identity details await userSsoIdentitiesQueries.updateById(id, { - detail: userInfo, + detail: safeParseUnknownJson(userInfo), }); const { name, avatar, id: identityId } = userInfo; @@ -331,7 +333,7 @@ const signInAndLinkWithSsoAuthentication = async ( userId, identityId, issuer, - detail: userInfo, + detail: safeParseUnknownJson(userInfo), }); // Sync the user name and avatar to the existing user if the connector has syncProfile enabled (sign-in) @@ -413,7 +415,7 @@ export const registerWithSsoAuthentication = async ( ssoConnectorId: connectorId, identityId: userInfo.id, issuer, - detail: userInfo, + detail: safeParseUnknownJson(userInfo), }); // JIT provision for new users signing up with SSO diff --git a/packages/core/src/utils/json.test.ts b/packages/core/src/utils/json.test.ts new file mode 100644 index 000000000..9749cdacd --- /dev/null +++ b/packages/core/src/utils/json.test.ts @@ -0,0 +1,35 @@ +import { safeParseUnknownJson } from './json.js'; + +describe('json utils test', () => { + it('should parse unknown json object properly', () => { + const unknownJson: Record = { + text: 'hello', + null: null, + number: 123, + boolean: true, + empty: undefined, + array: [1, 2, 3], + object: { + key: 'value', + number: 123, + array: [1, 2, 3], + empty: undefined, + }, + }; + + const result = safeParseUnknownJson(unknownJson); + + expect(result).toEqual({ + text: 'hello', + number: 123, + boolean: true, + null: null, + array: [1, 2, 3], + object: { + key: 'value', + number: 123, + array: [1, 2, 3], + }, + }); + }); +}); diff --git a/packages/core/src/utils/json.ts b/packages/core/src/utils/json.ts index ad2ab4e81..1fc302e44 100644 --- a/packages/core/src/utils/json.ts +++ b/packages/core/src/utils/json.ts @@ -1,5 +1,21 @@ +import { type JsonObject, jsonObjectGuard } from '@logto/schemas'; import { trySafe } from '@silverhand/essentials'; +import cleanDeep from 'clean-deep'; export const safeParseJson = (jsonString: string): unknown => // eslint-disable-next-line no-restricted-syntax trySafe(() => JSON.parse(jsonString) as unknown); + +// Safely parse Zod unknown JSON object to JsonObject +export const safeParseUnknownJson = (unknownJson: Record): JsonObject => + trySafe( + () => + jsonObjectGuard.safeParse( + cleanDeep(unknownJson, { + emptyArrays: false, + emptyObjects: false, + emptyStrings: false, + nullValues: false, + }) + ).data + ) ?? {};