mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
refactor(core): skip sign-in mode check for one-time token verification (#7200)
* refactor(core): skip sign-in mode check for one-time token verification * refactor(core): refactor per review comments * test: add integration test
This commit is contained in:
parent
ac7f0bbcd5
commit
5d087da9b1
8 changed files with 142 additions and 65 deletions
|
@ -1,12 +1,6 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { type ToZodObject } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
type OneTimeTokenContext,
|
||||
oneTimeTokenContextGuard,
|
||||
VerificationType,
|
||||
type User,
|
||||
} from '@logto/schemas';
|
||||
import { InteractionEvent, VerificationType, type User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -43,7 +37,6 @@ import {
|
|||
type VerificationRecordData,
|
||||
type VerificationRecordMap,
|
||||
} from './verifications/index.js';
|
||||
import { isOneTimeTokenVerificationRecordData } from './verifications/one-time-token-verification.js';
|
||||
import { VerificationRecordsMap } from './verifications/verification-records-map.js';
|
||||
|
||||
type InteractionStorage = {
|
||||
|
@ -52,7 +45,6 @@ type InteractionStorage = {
|
|||
profile?: InteractionProfile;
|
||||
mfa?: MfaData;
|
||||
verificationRecords?: VerificationRecordData[];
|
||||
oneTimeTokenContext?: OneTimeTokenContext;
|
||||
captcha?: {
|
||||
verified: boolean;
|
||||
skipped: boolean;
|
||||
|
@ -65,7 +57,6 @@ const interactionStorageGuard = z.object({
|
|||
profile: interactionProfileGuard.optional(),
|
||||
mfa: mfaDataGuard.optional(),
|
||||
verificationRecords: verificationRecordDataGuard.array().optional(),
|
||||
oneTimeTokenContext: oneTimeTokenContextGuard.optional(),
|
||||
captcha: z
|
||||
.object({
|
||||
verified: z.boolean(),
|
||||
|
@ -93,7 +84,7 @@ export default class ExperienceInteraction {
|
|||
/** The userId of the user for the current interaction. Only available once the user is identified. */
|
||||
private userId?: string;
|
||||
private userCache?: User;
|
||||
private oneTimeTokenContext?: OneTimeTokenContext;
|
||||
|
||||
/** The captcha verification status for the current interaction. */
|
||||
private readonly captcha = {
|
||||
verified: false,
|
||||
|
@ -158,7 +149,6 @@ export default class ExperienceInteraction {
|
|||
verified: false,
|
||||
skipped: false,
|
||||
},
|
||||
oneTimeTokenContext,
|
||||
} = result.data;
|
||||
|
||||
this.#interactionEvent = interactionEvent;
|
||||
|
@ -166,7 +156,6 @@ export default class ExperienceInteraction {
|
|||
this.profile = new Profile(libraries, queries, profile, interactionContext);
|
||||
this.mfa = new Mfa(libraries, queries, mfa, interactionContext);
|
||||
this.captcha = captcha;
|
||||
this.oneTimeTokenContext = oneTimeTokenContext;
|
||||
|
||||
for (const record of verificationRecords) {
|
||||
const instance = buildVerificationRecord(libraries, queries, record);
|
||||
|
@ -192,7 +181,10 @@ export default class ExperienceInteraction {
|
|||
* @throws RequestError with 400 if the interaction event is not `ForgotPassword` and the current interaction event is `ForgotPassword`
|
||||
*/
|
||||
public async setInteractionEvent(interactionEvent: InteractionEvent) {
|
||||
await this.signInExperienceValidator.guardInteractionEvent(interactionEvent);
|
||||
await this.signInExperienceValidator.guardInteractionEvent(
|
||||
interactionEvent,
|
||||
this.verificationRecords.get(VerificationType.OneTimeToken)?.isVerified
|
||||
);
|
||||
|
||||
// `ForgotPassword` interaction event can not interchanged with other events
|
||||
assertThat(
|
||||
|
@ -292,8 +284,6 @@ export default class ExperienceInteraction {
|
|||
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
|
||||
);
|
||||
|
||||
await this.signInExperienceValidator.guardInteractionEvent(InteractionEvent.Register);
|
||||
|
||||
if (verificationId) {
|
||||
const verificationRecord = this.getVerificationRecordById(verificationId);
|
||||
const verificationData = verificationRecord.toJson();
|
||||
|
@ -307,22 +297,18 @@ export default class ExperienceInteraction {
|
|||
|
||||
await this.profile.setProfileWithValidation(identifierProfile);
|
||||
|
||||
this.oneTimeTokenContext = conditional(
|
||||
isOneTimeTokenVerificationRecordData(verificationData) &&
|
||||
verificationData.oneTimeTokenContext
|
||||
);
|
||||
|
||||
// Save the updated profile data to the interaction storage
|
||||
await this.save();
|
||||
}
|
||||
|
||||
await this.signInExperienceValidator.guardInteractionEvent(
|
||||
InteractionEvent.Register,
|
||||
this.verificationRecords.get(VerificationType.OneTimeToken)?.isVerified
|
||||
);
|
||||
await this.guardCaptcha();
|
||||
await this.profile.assertUserMandatoryProfileFulfilled();
|
||||
|
||||
const user = await this.provisionLibrary.createUser(
|
||||
this.profile.data,
|
||||
this.oneTimeTokenContext?.jitOrganizationIds
|
||||
);
|
||||
const user = await this.provisionLibrary.createUser(this.profile.data);
|
||||
log?.append({ user });
|
||||
|
||||
this.userId = user.id;
|
||||
|
@ -496,8 +482,13 @@ export default class ExperienceInteraction {
|
|||
await this.mfa.assertUserMandatoryMfaFulfilled();
|
||||
}
|
||||
|
||||
const { socialIdentity, enterpriseSsoIdentity, syncedEnterpriseSsoIdentity, ...rest } =
|
||||
this.profile.data;
|
||||
const {
|
||||
socialIdentity,
|
||||
enterpriseSsoIdentity,
|
||||
syncedEnterpriseSsoIdentity,
|
||||
jitOrganizationIds,
|
||||
...rest
|
||||
} = this.profile.data;
|
||||
const { mfaSkipped, mfaVerifications } = this.mfa.toUserMfaVerifications();
|
||||
|
||||
// Update user profile
|
||||
|
@ -543,6 +534,14 @@ export default class ExperienceInteraction {
|
|||
await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity);
|
||||
}
|
||||
|
||||
// Provision organizations for one-time token that carries organization IDs in the context.
|
||||
if (jitOrganizationIds) {
|
||||
await this.provisionLibrary.provisionJitOrganization({
|
||||
userId: user.id,
|
||||
organizationIds: jitOrganizationIds,
|
||||
});
|
||||
}
|
||||
|
||||
const { provider } = this.tenant;
|
||||
|
||||
const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
|
||||
|
@ -560,7 +559,7 @@ export default class ExperienceInteraction {
|
|||
|
||||
/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
|
||||
public toJson(): InteractionStorage {
|
||||
const { interactionEvent, userId, captcha, oneTimeTokenContext } = this;
|
||||
const { interactionEvent, userId, captcha } = this;
|
||||
|
||||
return {
|
||||
interactionEvent,
|
||||
|
@ -568,7 +567,6 @@ export default class ExperienceInteraction {
|
|||
profile: this.profile.data,
|
||||
mfa: this.mfa.data,
|
||||
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
|
||||
oneTimeTokenContext,
|
||||
captcha,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -70,7 +70,12 @@ export const identifyUserByVerificationRecord = async (
|
|||
*/
|
||||
syncedProfile?: Pick<
|
||||
InteractionProfile,
|
||||
'enterpriseSsoIdentity' | 'syncedEnterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name'
|
||||
| 'enterpriseSsoIdentity'
|
||||
| 'syncedEnterpriseSsoIdentity'
|
||||
| 'jitOrganizationIds'
|
||||
| 'socialIdentity'
|
||||
| 'avatar'
|
||||
| 'name'
|
||||
>;
|
||||
}> => {
|
||||
// Check verification record can be used to identify a user using the `identifyUser` method.
|
||||
|
@ -83,9 +88,18 @@ export const identifyUserByVerificationRecord = async (
|
|||
switch (verificationRecord.type) {
|
||||
case VerificationType.Password:
|
||||
case VerificationType.EmailVerificationCode:
|
||||
case VerificationType.PhoneVerificationCode:
|
||||
case VerificationType.PhoneVerificationCode: {
|
||||
return {
|
||||
user: await verificationRecord.identifyUser(),
|
||||
};
|
||||
}
|
||||
case VerificationType.OneTimeToken: {
|
||||
return { user: await verificationRecord.identifyUser() };
|
||||
return {
|
||||
user: await verificationRecord.identifyUser(),
|
||||
syncedProfile: {
|
||||
jitOrganizationIds: verificationRecord.oneTimeTokenContext?.jitOrganizationIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
case VerificationType.Social: {
|
||||
const user = linkSocialIdentity
|
||||
|
|
|
@ -54,7 +54,7 @@ export class ProvisionLibrary {
|
|||
* - Provision all JIT organizations for the user if necessary.
|
||||
* - Assign the first user to the admin role and the default tenant organization membership. [OSS only]
|
||||
*/
|
||||
async createUser(profile: InteractionProfile, jitOrganizationIds?: string[]) {
|
||||
async createUser(profile: InteractionProfile) {
|
||||
const {
|
||||
libraries: {
|
||||
users: { generateUserId, insertUser },
|
||||
|
@ -84,7 +84,7 @@ export class ProvisionLibrary {
|
|||
await this.provisionForFirstAdminUser(user);
|
||||
}
|
||||
|
||||
await this.provisionNewUserJitOrganizations(user.id, profile, jitOrganizationIds);
|
||||
await this.provisionNewUserJitOrganizations(user.id, profile);
|
||||
|
||||
this.ctx.appendDataHookContext('User.Created', { user });
|
||||
|
||||
|
@ -110,6 +110,26 @@ export class ProvisionLibrary {
|
|||
await this.provisionNewUserJitOrganizations(userId, { enterpriseSsoIdentity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the user to the specified organizations. This function is called when an existing
|
||||
* user is invited to organization(s) by admin through one-time token (e.g. Magic link).
|
||||
*/
|
||||
async provisionJitOrganization(payload: OrganizationProvisionPayload) {
|
||||
const {
|
||||
libraries: { users: usersLibraries },
|
||||
} = this.tenantContext;
|
||||
|
||||
const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload);
|
||||
|
||||
for (const { organizationId } of provisionedOrganizations) {
|
||||
this.ctx.appendDataHookContext('Organization.Membership.Updated', {
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
return provisionedOrganizations;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to get the provision context for a new user registration.
|
||||
* It will return the provision context based on the current tenant and the request context.
|
||||
|
@ -201,14 +221,13 @@ export class ProvisionLibrary {
|
|||
*/
|
||||
private async provisionNewUserJitOrganizations(
|
||||
userId: string,
|
||||
{ primaryEmail, enterpriseSsoIdentity }: InteractionProfile,
|
||||
extraJitOrganizationIds?: string[]
|
||||
{ primaryEmail, enterpriseSsoIdentity, jitOrganizationIds }: InteractionProfile
|
||||
) {
|
||||
const extraJitOrganizations = condArray(
|
||||
extraJitOrganizationIds &&
|
||||
jitOrganizationIds &&
|
||||
(await this.provisionJitOrganization({
|
||||
userId,
|
||||
organizationIds: extraJitOrganizationIds,
|
||||
organizationIds: jitOrganizationIds,
|
||||
}))
|
||||
);
|
||||
if (enterpriseSsoIdentity) {
|
||||
|
@ -259,22 +278,6 @@ export class ProvisionLibrary {
|
|||
});
|
||||
}
|
||||
|
||||
private async provisionJitOrganization(payload: OrganizationProvisionPayload) {
|
||||
const {
|
||||
libraries: { users: usersLibraries },
|
||||
} = this.tenantContext;
|
||||
|
||||
const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload);
|
||||
|
||||
for (const { organizationId } of provisionedOrganizations) {
|
||||
this.ctx.appendDataHookContext('Organization.Membership.Updated', {
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
return provisionedOrganizations;
|
||||
}
|
||||
|
||||
private readonly getInitialUserRoles = (
|
||||
isInAdminTenant: boolean,
|
||||
isCreatingFirstAdminUser: boolean,
|
||||
|
|
|
@ -18,6 +18,7 @@ import { MockTenant } from '#src/test-utils/tenant.js';
|
|||
import { createNewCodeVerificationRecord } from '../verifications/code-verification.js';
|
||||
import { EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js';
|
||||
import { type VerificationRecord } from '../verifications/index.js';
|
||||
import { OneTimeTokenVerification } from '../verifications/one-time-token-verification.js';
|
||||
import { PasswordVerification } from '../verifications/password-verification.js';
|
||||
import { SocialVerification } from '../verifications/social-verification.js';
|
||||
|
||||
|
@ -83,6 +84,17 @@ const socialVerificationRecord = new SocialVerification(mockTenant.libraries, mo
|
|||
},
|
||||
});
|
||||
|
||||
const oneTimeTokenVerificationRecord = new OneTimeTokenVerification(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
{
|
||||
id: 'one_time_token_verification_id',
|
||||
type: VerificationType.OneTimeToken,
|
||||
identifier: { type: SignInIdentifier.Email, value: 'foo@logto.io' },
|
||||
verified: true,
|
||||
}
|
||||
);
|
||||
|
||||
describe('SignInExperienceValidator', () => {
|
||||
describe('guardInteractionEvent', () => {
|
||||
it('SignInMode.Register', async () => {
|
||||
|
@ -123,6 +135,15 @@ describe('SignInExperienceValidator', () => {
|
|||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register)
|
||||
).rejects.toMatchError(new RequestError({ code: 'auth.forbidden', status: 403 }));
|
||||
|
||||
// Should not throw if there's a verified one-time token. In this case, even the registration
|
||||
// is turned off, the user can still register.
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(
|
||||
InteractionEvent.Register,
|
||||
oneTimeTokenVerificationRecord.isVerified
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn)
|
||||
).resolves.not.toThrow();
|
||||
|
|
|
@ -82,9 +82,11 @@ export class SignInExperienceValidator {
|
|||
) {}
|
||||
|
||||
/**
|
||||
* @param event - The interaction event to guard
|
||||
* @param hasVerifiedOneTimeToken - Whether there is a verified one-time token verification record
|
||||
* @throws {RequestError} with status 403 if the interaction event is not allowed
|
||||
*/
|
||||
public async guardInteractionEvent(event: InteractionEvent) {
|
||||
public async guardInteractionEvent(event: InteractionEvent, hasVerifiedOneTimeToken = false) {
|
||||
const { signInMode } = await this.getSignInExperienceData();
|
||||
|
||||
switch (event) {
|
||||
|
@ -97,7 +99,10 @@ export class SignInExperienceValidator {
|
|||
}
|
||||
case InteractionEvent.Register: {
|
||||
assertThat(
|
||||
signInMode !== SignInMode.SignIn,
|
||||
signInMode !== SignInMode.SignIn ||
|
||||
// This guarantees new users can still be created through one-time token
|
||||
// authentication even if the registration is turned off.
|
||||
hasVerifiedOneTimeToken,
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
break;
|
||||
|
@ -112,7 +117,9 @@ export class SignInExperienceValidator {
|
|||
event: InteractionEvent.ForgotPassword | InteractionEvent.SignIn,
|
||||
verificationRecord: VerificationRecord
|
||||
) {
|
||||
await this.guardInteractionEvent(event);
|
||||
const hasVerifiedOneTimeToken =
|
||||
verificationRecord.type === VerificationType.OneTimeToken && verificationRecord.isVerified;
|
||||
await this.guardInteractionEvent(event, hasVerifiedOneTimeToken);
|
||||
|
||||
switch (event) {
|
||||
case InteractionEvent.SignIn: {
|
||||
|
|
|
@ -18,7 +18,6 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
import { type InteractionProfile } from '../../types.js';
|
||||
import { findUserByIdentifier } from '../utils.js';
|
||||
|
||||
import { type VerificationRecordData } from './index.js';
|
||||
import { type IdentifierVerificationRecord } from './verification-record.js';
|
||||
|
||||
export type OneTimeTokenVerificationRecordData = {
|
||||
|
@ -131,8 +130,9 @@ export class OneTimeTokenVerification
|
|||
);
|
||||
|
||||
const { value } = this.identifier;
|
||||
const { jitOrganizationIds } = this.context ?? {};
|
||||
|
||||
return { primaryEmail: value };
|
||||
return { primaryEmail: value, jitOrganizationIds };
|
||||
}
|
||||
|
||||
toJson(): OneTimeTokenVerificationRecordData {
|
||||
|
@ -141,9 +141,3 @@ export class OneTimeTokenVerification
|
|||
return { id, type, identifier, verified, oneTimeTokenContext: context };
|
||||
}
|
||||
}
|
||||
|
||||
export const isOneTimeTokenVerificationRecordData = (
|
||||
data?: VerificationRecordData
|
||||
): data is OneTimeTokenVerificationRecordData => {
|
||||
return oneTimeTokenVerificationRecordDataGuard.safeParse(data).success;
|
||||
};
|
||||
|
|
|
@ -33,6 +33,10 @@ export type InteractionProfile = {
|
|||
UserSsoIdentity,
|
||||
'identityId' | 'ssoConnectorId' | 'issuer' | 'detail'
|
||||
>;
|
||||
/**
|
||||
* This is from one-time token verification. User will be automatically added to the specified organizations.
|
||||
*/
|
||||
jitOrganizationIds?: string[];
|
||||
// Syncing the existing enterprise SSO identity detail
|
||||
syncedEnterpriseSsoIdentity?: Pick<UserSsoIdentity, 'identityId' | 'issuer' | 'detail'>;
|
||||
} & Pick<
|
||||
|
@ -78,6 +82,7 @@ export const interactionProfileGuard = Users.createGuard
|
|||
detail: true,
|
||||
})
|
||||
.optional(),
|
||||
jitOrganizationIds: z.array(z.string()).optional(),
|
||||
}) satisfies ToZodObject<InteractionProfile>;
|
||||
|
||||
/**
|
||||
|
|
|
@ -147,4 +147,39 @@ describe('Register interaction with one-time token happy path', () => {
|
|||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should allow user registration through one-time token even if the registration is turned off', async () => {
|
||||
// Turn off registration by setting sign-in mode to "SignIn"
|
||||
await updateSignInExperience({
|
||||
signInMode: SignInMode.SignIn,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
});
|
||||
|
||||
const client = await initExperienceClient({
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const oneTimeToken = await createOneTimeToken({
|
||||
email: 'foo@logto.io',
|
||||
});
|
||||
|
||||
const { verificationId } = await client.verifyOneTimeToken({
|
||||
token: oneTimeToken.token,
|
||||
identifier: {
|
||||
type: SignInIdentifier.Email,
|
||||
value: 'foo@logto.io',
|
||||
},
|
||||
});
|
||||
|
||||
await client.identifyUser({ verificationId });
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue