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:
parent
0c777c10f2
commit
6698679728
9 changed files with 112 additions and 20 deletions
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
35
packages/core/src/utils/json.test.ts
Normal file
35
packages/core/src/utils/json.test.ts
Normal 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],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
) ?? {};
|
||||
|
|
Loading…
Reference in a new issue