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 { 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',
});
}
}

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 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);
}

View file

@ -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.

View file

@ -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);

View file

@ -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<EnterpriseSsoVerificationRecordData>;
@ -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<User> {
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<Pick<InteractionProfile, 'enterpriseSsoIdentity'>> {
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),
},
};
}

View file

@ -28,6 +28,8 @@ export type InteractionProfile = {
UserSsoIdentity,
'identityId' | 'ssoConnectorId' | 'issuer' | 'detail'
>;
// Syncing the existing enterprise SSO identity detail
syncedEnterpriseSsoIdentity?: Pick<UserSsoIdentity, 'identityId' | 'issuer' | 'detail'>;
} & 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<InteractionProfile>;
/**

View file

@ -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

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 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<string, unknown>): JsonObject =>
trySafe(
() =>
jsonObjectGuard.safeParse(
cleanDeep(unknownJson, {
emptyArrays: false,
emptyObjects: false,
emptyStrings: false,
nullValues: false,
})
).data
) ?? {};