0
Fork 0
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:
Charles Zhao 2025-03-31 16:46:59 +08:00 committed by GitHub
parent ac7f0bbcd5
commit 5d087da9b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 142 additions and 65 deletions

View file

@ -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,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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>;
/**

View file

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