0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

fix(core): fix enterprise SSO unknown field not synced issue (#6763)

* fix(core): fix enterprise SSO unknown field not synced issue
fix enterprise SSO unknown field not synced issue

* fix(core): cleanup undefined in unknown json

cleanup undefined in unknown json

* fix(core): should sync the sso identity detail on sign-in (#6764)

should sync the sso identity detail on sign-in
This commit is contained in:
simeng-li 2024-11-04 16:48:36 +08:00 committed by GitHub
parent 0c777c10f2
commit 6698679728
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 112 additions and 20 deletions

View file

@ -10,6 +10,8 @@ import { sql, type CommonQueryMethods } from '@silverhand/slonik';
import SchemaQueries from '#src/utils/SchemaQueries.js'; import SchemaQueries from '#src/utils/SchemaQueries.js';
import { manyRows } from '#src/utils/sql.js'; import { manyRows } from '#src/utils/sql.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
export default class UserSsoIdentityQueries extends SchemaQueries< export default class UserSsoIdentityQueries extends SchemaQueries<
UserSsoIdentityKeys, UserSsoIdentityKeys,
CreateUserSsoIdentity, 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',
});
}
} }

View file

@ -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 profile data is not unique across users
* @throws {RequestError} with 422 if the required profile fields are missing * @throws {RequestError} with 422 if the required profile fields are missing
**/ **/
// eslint-disable-next-line complexity
public async submit() { public async submit() {
const { const {
queries: { users: userQueries }, queries: { users: userQueries, userSsoIdentities: userSsoIdentityQueries },
} = this.tenant; } = this.tenant;
// Identified // Identified
@ -425,7 +426,8 @@ export default class ExperienceInteraction {
await this.mfa.assertUserMandatoryMfaFulfilled(); 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(); const { mfaSkipped, mfaVerifications } = this.mfa.toUserMfaVerifications();
// Update user profile // Update user profile
@ -457,6 +459,16 @@ export default class ExperienceInteraction {
lastSignInAt: Date.now(), lastSignInAt: Date.now(),
}); });
// Sync SSO identity
if (syncedEnterpriseSsoIdentity) {
const { identityId, issuer, detail } = syncedEnterpriseSsoIdentity;
await userSsoIdentityQueries.updateUserSsoIdentityDetailByIdentityId(
issuer,
identityId,
detail
);
}
if (enterpriseSsoIdentity) { if (enterpriseSsoIdentity) {
await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity); await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity);
} }

View file

@ -68,7 +68,7 @@ export const identifyUserByVerificationRecord = async (
*/ */
syncedProfile?: Pick< syncedProfile?: Pick<
InteractionProfile, InteractionProfile,
'enterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name' 'enterpriseSsoIdentity' | 'syncedEnterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name'
>; >;
}> => { }> => {
// Check verification record can be used to identify a user using the `identifyUser` method. // 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: { case VerificationType.EnterpriseSso: {
try { try {
const user = await verificationRecord.identifyUser(); 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 }; return { user, syncedProfile };
} catch (error: unknown) { } catch (error: unknown) {
// Auto fallback to identify the related user if the user does not exist for enterprise SSO. // Auto fallback to identify the related user if the user does not exist for enterprise SSO.

View file

@ -58,7 +58,7 @@ export class ProvisionLibrary {
queries: { userSsoIdentities: userSsoIdentitiesQueries }, queries: { userSsoIdentities: userSsoIdentitiesQueries },
} = this.tenantContext; } = this.tenantContext;
const { socialIdentity, enterpriseSsoIdentity, ...rest } = profile; const { socialIdentity, enterpriseSsoIdentity, syncedEnterpriseSsoIdentity, ...rest } = profile;
const { isCreatingFirstAdminUser, initialUserRoles, customData } = const { isCreatingFirstAdminUser, initialUserRoles, customData } =
await this.getUserProvisionContext(profile); await this.getUserProvisionContext(profile);

View file

@ -1,4 +1,4 @@
import { socialUserInfoGuard, type SocialUserInfo, type ToZodObject } from '@logto/connector-kit'; import { type ToZodObject } from '@logto/connector-kit';
import { import {
VerificationType, VerificationType,
type JsonObject, type JsonObject,
@ -17,10 +17,12 @@ import {
getSsoAuthorizationUrl, getSsoAuthorizationUrl,
verifySsoIdentity, verifySsoIdentity,
} from '#src/routes/interaction/utils/single-sign-on.js'; } 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 Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { safeParseUnknownJson } from '#src/utils/json.js';
import type { InteractionProfile } from '../../types.js'; import type { InteractionProfile } from '../../types.js';
@ -34,7 +36,7 @@ export type EnterpriseSsoVerificationRecordData = {
/** /**
* The enterprise SSO identity returned by the connector. * The enterprise SSO identity returned by the connector.
*/ */
enterpriseSsoUserInfo?: SocialUserInfo; enterpriseSsoUserInfo?: ExtendedSocialUserInfo;
issuer?: string; issuer?: string;
}; };
@ -42,7 +44,7 @@ export const enterPriseSsoVerificationRecordDataGuard = z.object({
id: z.string(), id: z.string(),
connectorId: z.string(), connectorId: z.string(),
type: z.literal(VerificationType.EnterpriseSso), type: z.literal(VerificationType.EnterpriseSso),
enterpriseSsoUserInfo: socialUserInfoGuard.optional(), enterpriseSsoUserInfo: extendedSocialUserInfoGuard.optional(),
issuer: z.string().optional(), issuer: z.string().optional(),
}) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>; }) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>;
@ -60,7 +62,7 @@ export class EnterpriseSsoVerification
public readonly id: string; public readonly id: string;
public readonly type = VerificationType.EnterpriseSso; public readonly type = VerificationType.EnterpriseSso;
public readonly connectorId: string; public readonly connectorId: string;
public enterpriseSsoUserInfo?: SocialUserInfo; public enterpriseSsoUserInfo?: ExtendedSocialUserInfo;
public issuer?: string; public issuer?: string;
private connectorDataCache?: SupportedSsoConnector; private connectorDataCache?: SupportedSsoConnector;
@ -135,8 +137,7 @@ export class EnterpriseSsoVerification
} }
/** /**
* Identify the user by the enterprise SSO identity. * Identify the user by the enterprise SSO identity and sync the user SSO identity.
* If the user is not found, find the related user by the enterprise SSO identity and return the related user.
*/ */
async identifyUser(): Promise<User> { async identifyUser(): Promise<User> {
assertThat( assertThat(
@ -144,8 +145,6 @@ export class EnterpriseSsoVerification
new RequestError({ code: 'session.verification_failed', status: 400 }) new RequestError({ code: 'session.verification_failed', status: 400 })
); );
// TODO: sync userInfo and link sso identity
const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo(); const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo();
if (userSsoIdentityResult) { 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<Pick<InteractionProfile, 'enterpriseSsoIdentity'>> { toUserProfile(): Required<Pick<InteractionProfile, 'enterpriseSsoIdentity'>> {
assertThat( assertThat(
@ -184,7 +183,7 @@ export class EnterpriseSsoVerification
issuer: this.issuer, issuer: this.issuer,
ssoConnectorId: this.connectorId, ssoConnectorId: this.connectorId,
identityId: this.enterpriseSsoUserInfo.id, identityId: this.enterpriseSsoUserInfo.id,
detail: this.enterpriseSsoUserInfo, detail: safeParseUnknownJson(this.enterpriseSsoUserInfo),
}, },
}; };
} }

View file

@ -28,6 +28,8 @@ export type InteractionProfile = {
UserSsoIdentity, UserSsoIdentity,
'identityId' | 'ssoConnectorId' | 'issuer' | 'detail' 'identityId' | 'ssoConnectorId' | 'issuer' | 'detail'
>; >;
// Syncing the existing enterprise SSO identity detail
syncedEnterpriseSsoIdentity?: Pick<UserSsoIdentity, 'identityId' | 'issuer' | 'detail'>;
} & Pick< } & Pick<
CreateUser, CreateUser,
| 'avatar' | 'avatar'
@ -64,6 +66,13 @@ export const interactionProfileGuard = Users.createGuard
detail: true, detail: true,
}) })
.optional(), .optional(),
syncedEnterpriseSsoIdentity: UserSsoIdentities.guard
.pick({
identityId: true,
issuer: true,
detail: true,
})
.optional(),
}) satisfies ToZodObject<InteractionProfile>; }) satisfies ToZodObject<InteractionProfile>;
/** /**

View file

@ -1,5 +1,5 @@
/* eslint-disable max-lines -- will migrate this file to the latest experience APIs */ /* 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 { validateRedirectUrl } from '@logto/core-kit';
import { import {
InteractionEvent, InteractionEvent,
@ -17,9 +17,11 @@ import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import SamlConnector from '#src/sso/SamlConnector/index.js'; import SamlConnector from '#src/sso/SamlConnector/index.js';
import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/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 Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.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'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
@ -128,7 +130,7 @@ export const getSsoAuthorizationUrl = async (
type SsoAuthenticationResult = { type SsoAuthenticationResult = {
/** The issuer of the SSO provider, we need to store this in the user SSO identity to identify the provider. */ /** The issuer of the SSO provider, we need to store this in the user SSO identity to identify the provider. */
issuer: string; issuer: string;
userInfo: SocialUserInfo; userInfo: ExtendedSocialUserInfo;
}; };
/** /**
@ -268,7 +270,7 @@ const signInWithSsoAuthentication = async (
// Update the user's SSO identity details // Update the user's SSO identity details
await userSsoIdentitiesQueries.updateById(id, { await userSsoIdentitiesQueries.updateById(id, {
detail: userInfo, detail: safeParseUnknownJson(userInfo),
}); });
const { name, avatar, id: identityId } = userInfo; const { name, avatar, id: identityId } = userInfo;
@ -331,7 +333,7 @@ const signInAndLinkWithSsoAuthentication = async (
userId, userId,
identityId, identityId,
issuer, issuer,
detail: userInfo, detail: safeParseUnknownJson(userInfo),
}); });
// Sync the user name and avatar to the existing user if the connector has syncProfile enabled (sign-in) // 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, ssoConnectorId: connectorId,
identityId: userInfo.id, identityId: userInfo.id,
issuer, issuer,
detail: userInfo, detail: safeParseUnknownJson(userInfo),
}); });
// JIT provision for new users signing up with SSO // JIT provision for new users signing up with SSO

View file

@ -0,0 +1,35 @@
import { safeParseUnknownJson } from './json.js';
describe('json utils test', () => {
it('should parse unknown json object properly', () => {
const unknownJson: Record<string, unknown> = {
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],
},
});
});
});

View file

@ -1,5 +1,21 @@
import { type JsonObject, jsonObjectGuard } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials'; import { trySafe } from '@silverhand/essentials';
import cleanDeep from 'clean-deep';
export const safeParseJson = (jsonString: string): unknown => export const safeParseJson = (jsonString: string): unknown =>
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
trySafe(() => JSON.parse(jsonString) as unknown); trySafe(() => JSON.parse(jsonString) as unknown);
// Safely parse Zod unknown JSON object to JsonObject
export const safeParseUnknownJson = (unknownJson: Record<string, unknown>): JsonObject =>
trySafe(
() =>
jsonObjectGuard.safeParse(
cleanDeep(unknownJson, {
emptyArrays: false,
emptyObjects: false,
emptyStrings: false,
nullValues: false,
})
).data
) ?? {};