0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(core): refactor interaction API (#2686)

This commit is contained in:
simeng-li 2022-12-23 10:01:08 +08:00 committed by GitHub
parent 8135246e41
commit 15c7c1605a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1549 additions and 1720 deletions

View file

@ -8,7 +8,6 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
Identifier,
VerifiedRegisterInteractionResult,
InteractionContext,
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
} from '../types/index.js';
@ -52,10 +51,9 @@ jest.useFakeTimers().setSystemTime(now);
describe('submit action', () => {
const provider = createMockProvider();
const ctx: InteractionContext = {
const ctx = {
...createContextWithRouteParameters(),
...createMockLogContext(),
interactionPayload: { event: Event.SignIn },
};
const profile = {
username: 'username',
@ -66,6 +64,7 @@ describe('submit action', () => {
};
const userInfo = { id: 'foo', name: 'foo_social', avatar: 'avatar' };
const identifiers: Identifier[] = [
{
key: 'social',
@ -148,11 +147,14 @@ describe('submit action', () => {
profile: { password: 'password' },
};
await submitInteraction(interaction, ctx, provider);
expect(encryptUserPassword).toBeCalledWith('password');
expect(updateUserById).toBeCalledWith('foo', {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
});
expect(assignInteractionResults).not.toBeCalled();
});
});

View file

@ -1,6 +1,7 @@
import type { User } from '@logto/schemas';
import { Event } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import { getLogtoConnectorById } from '#src/connectors/index.js';
@ -9,7 +10,6 @@ import { encryptUserPassword, generateUserId, insertUser } from '#src/libraries/
import { findUserById, updateUserById } from '#src/queries/user.js';
import type {
InteractionContext,
Identifier,
VerifiedInteractionResult,
SocialIdentifier,
@ -86,7 +86,7 @@ const parseUserProfile = async (
export default async function submitInteraction(
interaction: VerifiedInteractionResult,
ctx: InteractionContext,
ctx: Context,
provider: Provider
) {
const { event, profile } = interaction;

View file

@ -1,6 +1,6 @@
import { ConnectorType } from '@logto/connector-kit';
import { Event, demoAppApplicationId } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockEsmWithActual, mockEsmDefault, mockEsm } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -9,8 +9,6 @@ import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createRequester } from '#src/utils/test-utils.js';
import type { InteractionContext } from './types/index.js';
const { jest } = import.meta;
// FIXME @Darcy: no more `enabled` for `connectors` table
@ -47,6 +45,43 @@ await mockEsmWithActual('#src/connectors/index.js', () => ({
}),
}));
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
assignInteractionResults: jest.fn(),
}));
const {
getSignInExperience,
verifySignInModeSettings,
verifyIdentifierSettings,
verifyProfileSettings,
} = mockEsm('./utils/sign-in-experience-validation.js', () => ({
getSignInExperience: jest.fn(async () => mockSignInExperience),
verifySignInModeSettings: jest.fn(),
verifyIdentifierSettings: jest.fn(),
verifyProfileSettings: jest.fn(),
}));
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
const { verifyIdentifierPayload, verifyIdentifier, verifyProfile, validateMandatoryUserProfile } =
await mockEsmWithActual('./verifications/index.js', () => ({
verifyIdentifierPayload: jest.fn(),
verifyIdentifier: jest.fn(),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
}));
const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual(
'./utils/interaction.js',
() => ({
mergeIdentifiers: jest.fn(),
storeInteractionResult: jest.fn(),
getInteractionStorage: jest.fn().mockResolvedValue({
event: Event.SignIn,
}),
})
);
const { sendPasscodeToIdentifier } = await mockEsmWithActual(
'./utils/passcode-validation.js',
() => ({
@ -54,30 +89,8 @@ const { sendPasscodeToIdentifier } = await mockEsmWithActual(
})
);
mockEsm('#src/libraries/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
const { verifyIdentifier, verifyProfile, validateMandatoryUserProfile } = mockEsm(
'./verifications/index.js',
() => ({
verifyIdentifier: jest.fn(),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
})
);
const { default: submitInteraction } = mockEsm('./actions/submit-interaction.js', () => ({
default: jest.fn((_interaction, ctx: InteractionContext) => {
ctx.body = { redirectUri: 'logto.io' };
}),
}));
const { getInteractionStorage } = mockEsm('./utils/interaction.js', () => ({
getInteractionStorage: jest.fn(),
}));
const { createLog, prependAllLogEntries } = createMockLogContext();
mockEsmDefault(
'#src/middleware/koa-audit-log.js',
// eslint-disable-next-line unicorn/consistent-function-scoping
@ -89,35 +102,21 @@ mockEsmDefault(
}
);
const koaInteractionBodyGuard = await pickDefault(
import('./middleware/koa-interaction-body-guard.js')
);
const koaSessionSignInExperienceGuard = await pickDefault(
import('./middleware/koa-session-sign-in-experience-guard.js')
);
const koaInteractionBodyGuardSpy = mockEsmDefault(
'./middleware/koa-interaction-body-guard.js',
() => jest.fn(koaInteractionBodyGuard)
);
const koaSessionSignInExperienceGuardSpy = mockEsmDefault(
'./middleware/koa-session-sign-in-experience-guard.js',
() => jest.fn(koaSessionSignInExperienceGuard)
);
const {
default: interactionRoutes,
verificationPrefix,
verificationPath,
interactionPrefix,
} = await import('./index.js');
describe('session -> interactionRoutes', () => {
const baseProviderMock = {
params: {},
jti: 'jti',
client_id: demoAppApplicationId,
};
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
provider: createMockProvider(
jest.fn().mockResolvedValue({ params: {}, jti: 'jti', client_id: demoAppApplicationId })
),
provider: createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
});
afterEach(() => {
@ -127,119 +126,126 @@ describe('session -> interactionRoutes', () => {
describe('PUT /interaction', () => {
const path = interactionPrefix;
it('sign-in event should call methods properly', async () => {
it('should call validations properly', async () => {
const body = {
event: Event.SignIn,
identifier: {
username: 'username',
password: 'password',
},
identifier: { email: 'email@logto.io', password: 'password' },
profile: { phone: '1234567890' },
};
const response = await sessionRequest.put(path).send(body);
expect(koaInteractionBodyGuardSpy).toBeCalled();
expect(koaSessionSignInExperienceGuardSpy).toBeCalled();
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).toBeCalled();
expect(submitInteraction).toBeCalled();
expect(response.status).toEqual(200);
expect(response.body).toEqual({ redirectUri: 'logto.io' });
});
it('forgot password event should not call UserProfile validation', async () => {
const body = {
event: Event.ForgotPassword,
identifier: {
email: 'email@logto.io',
passcode: 'passcode',
},
profile: {
password: 'password',
},
};
const response = await sessionRequest.put(path).send(body);
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(submitInteraction).toBeCalled();
expect(response.status).toEqual(200);
expect(getSignInExperience).toBeCalled();
expect(verifySignInModeSettings).toBeCalled();
expect(verifyIdentifierSettings).toBeCalled();
expect(verifyProfileSettings).toBeCalled();
expect(verifyIdentifierPayload).toBeCalled();
expect(storeInteractionResult).toBeCalled();
expect(response.status).toEqual(204);
});
});
describe('PATCH /interaction', () => {
const path = interactionPrefix;
afterEach(() => {
jest.clearAllMocks();
describe('DELETE /interaction', () => {
it('should call assignInteractionResult properly', async () => {
await sessionRequest.delete(`${interactionPrefix}`);
expect(assignInteractionResults).toBeCalled();
});
});
it('sign-in event with register event interaction session in record should call methods properly', async () => {
getInteractionStorage.mockResolvedValueOnce({ event: Event.Register });
describe('PUT /interaction/event', () => {
const path = `${interactionPrefix}/event`;
const body = {
it('should call verifySignInModeSettings properly', async () => {
getInteractionStorage.mockResolvedValueOnce({
event: Event.SignIn,
});
const body = {
event: Event.Register,
};
const response = await sessionRequest.patch(path).send(body);
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).toBeCalled();
expect(submitInteraction).toBeCalled();
expect(response.status).toEqual(200);
expect(response.body).toEqual({ redirectUri: 'logto.io' });
const response = await sessionRequest.put(path).send(body);
expect(getInteractionStorage).toBeCalled();
expect(verifySignInModeSettings).toBeCalled();
expect(storeInteractionResult).toBeCalled();
expect(response.status).toEqual(204);
});
it('sign-in event with forgot password event interaction session in record should reject', async () => {
getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword });
it('should reject if switch sign-in event to forgot-password directly', async () => {
getInteractionStorage.mockResolvedValueOnce({
event: Event.SignIn,
});
const body = {
event: Event.SignIn,
event: Event.ForgotPassword,
};
const response = await sessionRequest.patch(path).send(body);
expect(verifyIdentifier).not.toBeCalled();
expect(verifyProfile).not.toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(submitInteraction).not.toBeCalled();
const response = await sessionRequest.put(`${interactionPrefix}/event`).send(body);
expect(verifySignInModeSettings).toBeCalled();
expect(storeInteractionResult).not.toBeCalled();
expect(response.status).toEqual(404);
});
it('Forgot event with forgot password event interaction session in record should call methods properly', async () => {
getInteractionStorage.mockResolvedValueOnce({ event: Event.ForgotPassword });
it('should reject if switch forgot-password to sign-in directly', async () => {
getInteractionStorage.mockResolvedValueOnce({
event: Event.ForgotPassword,
});
const body = {
event: Event.ForgotPassword,
event: Event.SignIn,
};
const response = await sessionRequest.patch(path).send(body);
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(submitInteraction).toBeCalled();
expect(response.status).toEqual(200);
expect(response.body).toEqual({ redirectUri: 'logto.io' });
});
it('Forgot event with sign-in event interaction session in record should call methods properly', async () => {
getInteractionStorage.mockResolvedValueOnce({ event: Event.SignIn });
const body = {
event: Event.ForgotPassword,
};
const response = await sessionRequest.patch(path).send(body);
expect(verifyIdentifier).not.toBeCalled();
expect(verifyProfile).not.toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(submitInteraction).not.toBeCalled();
const response = await sessionRequest.put(`${interactionPrefix}/event`).send(body);
expect(verifySignInModeSettings).toBeCalled();
expect(storeInteractionResult).not.toBeCalled();
expect(response.status).toEqual(404);
});
});
describe('POST /verification/passcode', () => {
const path = `${verificationPrefix}/passcode`;
describe('PATCH /interaction/identifiers', () => {
const path = `${interactionPrefix}/identifiers`;
it('should update identifiers properly', async () => {
const body = {
email: 'email@logto.io',
passcode: 'passcode',
};
const response = await sessionRequest.patch(path).send(body);
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifierPayload).toBeCalled();
expect(mergeIdentifiers).toBeCalled();
expect(storeInteractionResult).toBeCalled();
// Supertest does not return the error body
expect(response.status).toEqual(204);
});
});
describe('/interaction/profile', () => {
const path = `${interactionPrefix}/profile`;
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
provider: createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
});
it('PATCH /interaction/profile', async () => {
const body = {
email: 'email@logto.io',
};
const response = await sessionRequest.patch(path).send(body);
expect(verifyProfileSettings).toBeCalled();
expect(getInteractionStorage).toBeCalled();
expect(storeInteractionResult).toBeCalled();
expect(response.status).toEqual(204);
});
it('DELETE /interaction/profile', async () => {
const response = await sessionRequest.delete(path);
expect(getInteractionStorage).toBeCalled();
expect(storeInteractionResult).toBeCalled();
expect(response.status).toEqual(204);
});
});
describe('POST /interaction/verification/passcode', () => {
const path = `${interactionPrefix}/${verificationPath}/passcode`;
it('should call send passcode properly', async () => {
const body = {
event: Event.SignIn,
@ -247,13 +253,40 @@ describe('session -> interactionRoutes', () => {
};
const response = await sessionRequest.post(path).send(body);
expect(getInteractionStorage).toBeCalled();
expect(sendPasscodeToIdentifier).toBeCalledWith(body, 'jti', createLog);
expect(response.status).toEqual(204);
});
});
describe('submit interaction', () => {
const path = `${interactionPrefix}/submit`;
it('should call identifier and profile verification properly', async () => {
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).toBeCalled();
expect(submitInteraction).toBeCalled();
});
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
getInteractionStorage.mockResolvedValueOnce({
event: Event.ForgotPassword,
});
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(submitInteraction).toBeCalled();
});
});
describe('POST /verification/social/authorization-uri', () => {
const path = `${verificationPrefix}/social/authorization-uri`;
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post(path).send({

View file

@ -1,8 +1,9 @@
import type { ConnectorSession } from '@logto/connector-kit';
import type { LogtoErrorCode } from '@logto/phrases';
import { Event } from '@logto/schemas';
import { Event, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { Provider } from 'oidc-provider';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { assignInteractionResults } from '#src/libraries/session.js';
@ -13,20 +14,29 @@ import assertThat from '#src/utils/assert-that.js';
import type { AnonymousRouter } from '../types.js';
import submitInteraction from './actions/submit-interaction.js';
import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js';
import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js';
import { sendPasscodePayloadGuard, getSocialAuthorizationUrlPayloadGuard } from './types/guard.js';
import { getInteractionStorage } from './utils/interaction.js';
import { sendPasscodePayloadGuard, socialAuthorizationUrlPayloadGuard } from './types/guard.js';
import {
getInteractionStorage,
storeInteractionResult,
mergeIdentifiers,
} from './utils/interaction.js';
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
import {
getSignInExperience,
verifySignInModeSettings,
verifyIdentifierSettings,
verifyProfileSettings,
} from './utils/sign-in-experience-validation.js';
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
import {
verifyIdentifierPayload,
verifyIdentifier,
verifyProfile,
validateMandatoryUserProfile,
} from './verifications/index.js';
export const interactionPrefix = '/interaction';
export const verificationPrefix = '/verification';
export const verificationPath = 'verification';
export default function interactionRoutes<T extends AnonymousRouter>(
router: T,
@ -50,64 +60,49 @@ export default function interactionRoutes<T extends AnonymousRouter>(
}
});
// Create a new interaction
router.put(
interactionPrefix,
koaInteractionBodyGuard(),
koaSessionSignInExperienceGuard(provider),
koaGuard({
body: z.object({
event: eventGuard,
identifier: identifierPayloadGuard.optional(),
profile: profileGuard.optional(),
}),
}),
async (ctx, next) => {
const { event } = ctx.interactionPayload;
const { event, identifier, profile } = ctx.guard.body;
const experience = await getSignInExperience(ctx, provider);
// Check interaction session
await provider.interactionDetails(ctx.req, ctx.res);
verifySignInModeSettings(event, experience);
const identifierVerifiedInteraction = await verifyIdentifier(ctx, provider);
const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction);
if (event !== Event.ForgotPassword) {
await validateMandatoryUserProfile(ctx, interaction);
if (identifier) {
verifyIdentifierSettings(identifier, experience);
}
await submitInteraction(interaction, ctx, provider);
if (profile) {
verifyProfileSettings(profile, experience);
}
return next();
}
);
const verifiedIdentifier = identifier && [
await verifyIdentifierPayload(ctx, provider, identifier, {
event,
}),
];
router.patch(
interactionPrefix,
koaInteractionBodyGuard(),
koaSessionSignInExperienceGuard(provider),
async (ctx, next) => {
const { event } = ctx.interactionPayload;
const interactionStorage = await getInteractionStorage(ctx, provider);
// Forgot Password specific event interaction can't be shared with other types of interactions
assertThat(
event === Event.ForgotPassword
? interactionStorage.event === Event.ForgotPassword
: interactionStorage.event !== Event.ForgotPassword,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
const identifierVerifiedInteraction = await verifyIdentifier(
await storeInteractionResult(
{ event, identifiers: verifiedIdentifier, profile },
ctx,
provider,
interactionStorage
provider
);
const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction);
if (event !== Event.ForgotPassword) {
await validateMandatoryUserProfile(ctx, interaction);
}
await submitInteraction(interaction, ctx, provider);
ctx.status = 204;
return next();
}
);
// Delete Interaction
router.delete(interactionPrefix, async (ctx, next) => {
await provider.interactionDetails(ctx.req, ctx.res);
const error: LogtoErrorCode = 'oidc.aborted';
@ -116,12 +111,130 @@ export default function interactionRoutes<T extends AnonymousRouter>(
return next();
});
router.post(
`${verificationPrefix}/social/authorization-uri`,
koaGuard({ body: getSocialAuthorizationUrlPayloadGuard }),
// Update Interaction Event
router.put(
`${interactionPrefix}/event`,
koaGuard({ body: z.object({ event: eventGuard }) }),
async (ctx, next) => {
// Check interaction session
await provider.interactionDetails(ctx.req, ctx.res);
const { event } = ctx.guard.body;
verifySignInModeSettings(event, await getSignInExperience(ctx, provider));
const interactionStorage = await getInteractionStorage(ctx, provider);
// Forgot Password specific event interaction storage can't be shared with other types of interactions
assertThat(
event === Event.ForgotPassword
? interactionStorage.event === Event.ForgotPassword
: interactionStorage.event !== Event.ForgotPassword,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);
if (event !== interactionStorage.event) {
await storeInteractionResult({ event }, ctx, provider, true);
}
ctx.status = 204;
return next();
}
);
// Update Interaction Identifier
router.patch(
`${interactionPrefix}/identifiers`,
koaGuard({
body: identifierPayloadGuard,
}),
async (ctx, next) => {
const identifierPayload = ctx.guard.body;
verifyIdentifierSettings(identifierPayload, await getSignInExperience(ctx, provider));
const interactionStorage = await getInteractionStorage(ctx, provider);
const verifiedIdentifier = await verifyIdentifierPayload(
ctx,
provider,
identifierPayload,
interactionStorage
);
const identifiers = mergeIdentifiers(verifiedIdentifier, interactionStorage.identifiers);
await storeInteractionResult({ identifiers }, ctx, provider, true);
ctx.status = 204;
return next();
}
);
// Update Interaction Profile
router.patch(
`${interactionPrefix}/profile`,
koaGuard({
body: profileGuard,
}),
async (ctx, next) => {
const profilePayload = ctx.guard.body;
verifyProfileSettings(profilePayload, await getSignInExperience(ctx, provider));
const interactionStorage = await getInteractionStorage(ctx, provider);
await storeInteractionResult(
{
profile: {
...interactionStorage.profile,
...profilePayload,
},
},
ctx,
provider,
true
);
ctx.status = 204;
return next();
}
);
// Delete Interaction Profile
router.delete(`${interactionPrefix}/profile`, async (ctx, next) => {
const interactionStorage = await getInteractionStorage(ctx, provider);
const { profile, ...rest } = interactionStorage;
await storeInteractionResult(rest, ctx, provider);
ctx.status = 204;
return next();
});
// Submit Interaction
router.post(`${interactionPrefix}/submit`, async (ctx, next) => {
const interactionStorage = await getInteractionStorage(ctx, provider);
const { event } = interactionStorage;
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
if (event !== Event.ForgotPassword) {
await validateMandatoryUserProfile(ctx, provider, verifiedInteraction);
}
await submitInteraction(verifiedInteraction, ctx, provider);
return next();
});
// Create social authorization url interaction verification
router.post(
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
koaGuard({ body: socialAuthorizationUrlPayloadGuard }),
async (ctx, next) => {
// Check interaction exists
await getInteractionStorage(ctx, provider);
const redirectTo = await createSocialAuthorizationUrl(
ctx.guard.body,
@ -135,13 +248,16 @@ export default function interactionRoutes<T extends AnonymousRouter>(
}
);
// Create passwordless interaction passcode
router.post(
`${verificationPrefix}/passcode`,
`${interactionPrefix}/${verificationPath}/passcode`,
koaGuard({
body: sendPasscodePayloadGuard,
}),
async (ctx, next) => {
// Check interaction session
// Check interaction exists
await getInteractionStorage(ctx, provider);
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog);

View file

@ -1,150 +0,0 @@
import { Event } from '@logto/schemas';
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
import type { Context } from 'koa';
import { interactionMocks } from '#src/__mocks__/interactions.js';
import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
const { jest } = import.meta;
mockEsmDefault('koa-body', () => emptyMiddleware);
const koaInteractionBodyGuard = await pickDefault(import('./koa-interaction-body-guard.js'));
describe('koaInteractionBodyGuard', () => {
const baseCtx = createContextWithRouteParameters();
const next = jest.fn();
describe('event', () => {
it('invalid event should throw', async () => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: 'test',
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
});
it.each([Event.SignIn, Event.ForgotPassword])('%p should parse successfully', async (event) => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event,
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
expect(ctx.interactionPayload.event).toEqual(event);
});
it('register should parse successfully', async () => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: Event.Register,
profile: { username: 'username', password: 'password' },
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
expect(ctx.interactionPayload.event).toEqual(Event.Register);
});
});
describe('identifier', () => {
it('invalid identifier should throw', async () => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: Event.SignIn,
identifier: {
username: 'username',
passcode: 'passcode',
},
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
});
it.each(interactionMocks)('interaction methods should parse successfully', async (input) => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: Event.SignIn,
identifier: input,
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
expect(ctx.interactionPayload.identifier).toEqual(input);
});
});
describe('profile', () => {
it('invalid profile format should throw', async () => {
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: Event.SignIn,
profile: {
email: 'username',
},
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
});
it('profile should resolve properly', async () => {
const profile = {
email: 'foo@logto.io',
phone: '123123',
username: 'username',
password: '123456',
connectorId: 'connectorId',
};
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
...baseCtx,
request: {
...baseCtx.request,
body: {
event: Event.SignIn,
profile,
},
},
interactionPayload: { event: Event.SignIn },
};
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
expect(ctx.interactionPayload.profile).toEqual(profile);
});
});
});

View file

@ -1,37 +0,0 @@
import type { MiddlewareType } from 'koa';
import koaBody from 'koa-body';
import RequestError from '#src/errors/RequestError/index.js';
import { interactionPayloadGuard } from '../types/guard.js';
import type { InteractionPayload } from '../types/index.js';
export type WithGuardedIdentifierPayloadContext<ContextT> = ContextT & {
interactionPayload: InteractionPayload;
};
const parse = (data: unknown) => {
try {
return interactionPayloadGuard.parse(data);
} catch (error: unknown) {
throw new RequestError({ code: 'guard.invalid_input' }, error);
}
};
/**
* Need this as our koaGuard does not infer the body type properly
* from the ZodEffects Output after data transform
*/
export default function koaInteractionBodyGuard<StateT, ContextT, ResponseT>(): MiddlewareType<
StateT,
WithGuardedIdentifierPayloadContext<ContextT>,
ResponseT
> {
return async (ctx, next) => {
return koaBody<StateT, ContextT>()(ctx, async () => {
ctx.interactionPayload = parse(ctx.request.body);
return next();
});
};
}

View file

@ -1,53 +0,0 @@
import type { SignInExperience } from '@logto/schemas';
import { Event } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { Provider } from 'oidc-provider';
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
import {
signInModeValidation,
identifierValidation,
profileValidation,
} from '../utils/sign-in-experience-validation.js';
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
export type WithSignInExperienceContext<ContextT> = ContextT & {
signInExperience: SignInExperience;
};
export default function koaSessionSignInExperienceGuard<
StateT,
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>,
ResponseBodyT
>(
provider: Provider
): MiddlewareType<StateT, WithSignInExperienceContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
const { event, identifier, profile } = ctx.interactionPayload;
if (event === Event.ForgotPassword) {
return next();
}
const signInExperience = await getSignInExperienceForApplication(
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
);
signInModeValidation(event, signInExperience);
if (identifier) {
identifierValidation(identifier, signInExperience);
}
if (profile) {
profileValidation(profile, signInExperience);
}
ctx.signInExperience = signInExperience;
return next();
};
}

View file

@ -1,51 +0,0 @@
import { Event } from '@logto/schemas';
import { mockEsm, pickDefault } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
mockEsm('#src/libraries/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
const mockUtils = {
signInModeValidation: jest.fn(),
identifierValidation: jest.fn(),
profileValidation: jest.fn(),
};
mockEsm('../utils/sign-in-experience-validation.js', () => mockUtils);
const koaSessionSignInExperienceGuard = await pickDefault(
import('./koa-session-sign-in-experience-guard.js')
);
describe('koaSessionSignInExperienceGuard', () => {
const baseCtx = createContextWithRouteParameters();
const next = jest.fn();
it('should call validation method properly', async () => {
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier: { username: 'username', password: 'password' },
profile: { email: 'email' },
}),
signInExperience: mockSignInExperience,
};
const provider = createMockProvider();
await koaSessionSignInExperienceGuard(provider)(ctx, next);
expect(mockUtils.signInModeValidation).toBeCalledWith(Event.SignIn, mockSignInExperience);
expect(mockUtils.identifierValidation).toBeCalledWith(
{ username: 'username', password: 'password' },
mockSignInExperience
);
expect(mockUtils.profileValidation).toBeCalledWith({ email: 'email' }, mockSignInExperience);
});
});

View file

@ -6,38 +6,12 @@ import {
socialConnectorPayloadGuard,
eventGuard,
profileGuard,
identifierPayloadGuard,
Event,
} from '@logto/schemas';
import { z } from 'zod';
import { socialUserInfoGuard } from '#src/connectors/types.js';
// Interaction Payload Guard
const forgotPasswordInteractionPayloadGuard = z.object({
event: z.literal(Event.ForgotPassword),
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
profile: z.object({ password: z.string() }).optional(),
});
const registerInteractionPayloadGuard = z.object({
event: z.literal(Event.Register),
identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(),
profile: profileGuard,
});
const signInInteractionPayloadGuard = z.object({
event: z.literal(Event.SignIn),
identifier: identifierPayloadGuard.optional(),
profile: profileGuard.optional(),
});
export const interactionPayloadGuard = z.discriminatedUnion('event', [
signInInteractionPayloadGuard,
registerInteractionPayloadGuard,
forgotPasswordInteractionPayloadGuard,
]);
// Passcode Send Route Payload Guard
export const sendPasscodePayloadGuard = z.union([
z.object({
@ -51,7 +25,7 @@ export const sendPasscodePayloadGuard = z.union([
]);
// Social Authorization Uri Route Payload Guard
export const getSocialAuthorizationUrlPayloadGuard = z.object({
export const socialAuthorizationUrlPayloadGuard = z.object({
connectorId: z.string(),
state: z.string(),
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),

View file

@ -6,18 +6,13 @@ import type {
PhonePasscodePayload,
Event,
} from '@logto/schemas';
import type { Context } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { z } from 'zod';
import type { SocialUserInfo } from '#src/connectors/types.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithGuardedIdentifierPayloadContext } from '../middleware/koa-interaction-body-guard.js';
import type {
interactionPayloadGuard,
sendPasscodePayloadGuard,
getSocialAuthorizationUrlPayloadGuard,
socialAuthorizationUrlPayloadGuard,
accountIdIdentifierGuard,
verifiedEmailIdentifierGuard,
verifiedPhoneIdentifierGuard,
@ -31,9 +26,7 @@ import type {
forgotPasswordProfileGuard,
} from './guard.js';
// Payload Types
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
/* Payload Types */
export type PasswordIdentifierPayload =
| UsernamePasswordPayload
| EmailPasswordPayload
@ -43,9 +36,10 @@ export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayl
export type SendPasscodePayload = z.infer<typeof sendPasscodePayloadGuard>;
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>;
// Interaction Types
/* Interaction Types */
// Identifier
export type AccountIdIdentifier = z.infer<typeof accountIdIdentifierGuard>;
export type VerifiedEmailIdentifier = z.infer<typeof verifiedEmailIdentifierGuard>;
@ -56,21 +50,13 @@ export type SocialIdentifier = z.infer<typeof socialIdentifierGuard>;
export type Identifier = z.infer<typeof identifierGuard>;
export type AnonymousInteractionResult = z.infer<typeof anonymousInteractionResultGuard>;
// Profile
export type RegisterSafeProfile = z.infer<typeof registerProfileSafeGuard>;
export type ForgotPasswordProfile = z.infer<typeof forgotPasswordProfileGuard>;
export type VerifiedRegisterInteractionResult = z.infer<
typeof verifiedRegisterInteractionResultGuard
>;
export type VerifiedSignInInteractionResult = z.infer<typeof verifiedSignInteractionResultGuard>;
export type VerifiedForgotPasswordInteractionResult = z.infer<
typeof verifiedForgotPasswordInteractionResultGuard
>;
// Interaction
export type AnonymousInteractionResult = z.infer<typeof anonymousInteractionResultGuard>;
export type RegisterInteractionResult = Omit<AnonymousInteractionResult, 'event'> & {
event: Event.Register;
@ -84,14 +70,6 @@ export type ForgotPasswordInteractionResult = Omit<AnonymousInteractionResult, '
event: Event.ForgotPassword;
};
export type PreAccountVerifiedInteractionResult =
| SignInInteractionResult
| ForgotPasswordInteractionResult;
export type PayloadVerifiedInteractionResult =
| RegisterInteractionResult
| PreAccountVerifiedInteractionResult;
export type AccountVerifiedInteractionResult =
| (Omit<SignInInteractionResult, 'accountId'> & {
accountId: string;
@ -104,15 +82,21 @@ export type IdentifierVerifiedInteractionResult =
| RegisterInteractionResult
| AccountVerifiedInteractionResult;
export type VerifiedRegisterInteractionResult = z.infer<
typeof verifiedRegisterInteractionResultGuard
>;
export type VerifiedSignInInteractionResult = z.infer<typeof verifiedSignInteractionResultGuard>;
export type VerifiedForgotPasswordInteractionResult = z.infer<
typeof verifiedForgotPasswordInteractionResultGuard
>;
export type VerifiedInteractionResult =
| VerifiedRegisterInteractionResult
| VerifiedSignInInteractionResult
| VerifiedForgotPasswordInteractionResult;
export type InteractionContext = WithGuardedIdentifierPayloadContext<
WithLogContext<IRouterParamContext & Context>
>;
export type UserIdentity =
| { username: string }
| { email: string }

View file

@ -1,42 +1,52 @@
import type { Identifier } from '../types/index.js';
import { mergeIdentifiers } from './interaction.js';
import { mergeIdentifiers, categorizeIdentifiers } from './interaction.js';
describe('interaction utils', () => {
const usernameIdentifier: Identifier = { key: 'accountId', value: 'foo' };
const emailIdentifier: Identifier = { key: 'emailVerified', value: 'foo@logto.io' };
const phoneIdentifier: Identifier = { key: 'phoneVerified', value: '12346' };
const socialIdentifier: Identifier = {
key: 'social',
connectorId: 'foo_connector',
userInfo: { id: 'foo' },
};
describe('mergeIdentifiers', () => {
it('new identifiers only ', () => {
expect(mergeIdentifiers([usernameIdentifier])).toEqual([usernameIdentifier]);
expect(mergeIdentifiers(usernameIdentifier)).toEqual([usernameIdentifier]);
});
it('same identifiers should replace', () => {
expect(mergeIdentifiers([usernameIdentifier], [{ key: 'accountId', value: 'foo2' }])).toEqual(
[usernameIdentifier]
);
expect(mergeIdentifiers(usernameIdentifier, [{ key: 'accountId', value: 'foo2' }])).toEqual([
usernameIdentifier,
]);
});
it('different identifiers should merge', () => {
expect(mergeIdentifiers([emailIdentifier], [usernameIdentifier])).toEqual([
expect(mergeIdentifiers(emailIdentifier, [usernameIdentifier])).toEqual([
usernameIdentifier,
emailIdentifier,
]);
expect(mergeIdentifiers([usernameIdentifier], [emailIdentifier, phoneIdentifier])).toEqual([
expect(mergeIdentifiers(usernameIdentifier, [emailIdentifier, phoneIdentifier])).toEqual([
emailIdentifier,
phoneIdentifier,
usernameIdentifier,
]);
});
});
it('mixed identifiers should replace and merge', () => {
describe('categorizeIdentifiers', () => {
it('should categorize identifiers', () => {
expect(
mergeIdentifiers(
[phoneIdentifier, usernameIdentifier],
[emailIdentifier, { key: 'phoneVerified', value: '465789' }]
categorizeIdentifiers(
[usernameIdentifier, emailIdentifier, phoneIdentifier, socialIdentifier],
{ email: 'foo@logto.io', connectorId: 'foo_connector' }
)
).toEqual([emailIdentifier, phoneIdentifier, usernameIdentifier]);
).toEqual({
userAccountIdentifiers: [usernameIdentifier, phoneIdentifier],
profileIdentifiers: [emailIdentifier, socialIdentifier],
});
});
});
});

View file

@ -29,17 +29,17 @@ const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => {
};
// Unique identifier type is required
export const mergeIdentifiers = (newIdentifiers: Identifier[], oldIdentifiers?: Identifier[]) => {
export const mergeIdentifiers = (newIdentifier: Identifier, oldIdentifiers?: Identifier[]) => {
if (!oldIdentifiers) {
return newIdentifiers;
return [newIdentifier];
}
// Filter out identifiers with the same key in the oldIdentifiers and replaced with new ones
const leftOvers = oldIdentifiers.filter((oldIdentifier) => {
return !newIdentifiers.some((newIdentifier) => newIdentifier.key === oldIdentifier.key);
});
const leftOvers = oldIdentifiers.filter(
(oldIdentifier) => newIdentifier.key !== oldIdentifier.key
);
return [...leftOvers, ...newIdentifiers];
return [...leftOvers, newIdentifier];
};
/**
@ -80,32 +80,40 @@ export const isAccountVerifiedInteractionResult = (
interaction: AnonymousInteractionResult
): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId);
type Options = {
merge?: boolean;
};
export const storeInteractionResult = async (
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: Event },
ctx: Context,
provider: Provider
provider: Provider,
merge = false
) => {
// The "mergeWithLastSubmission" will only merge current request's interaction results,
// manually merge with previous interaction results
// refer to: https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const details = merge ? await provider.interactionDetails(ctx.req, ctx.res) : undefined;
await provider.interactionResult(
ctx.req,
ctx.res,
{ ...result, ...interaction },
{ mergeWithLastSubmission: true }
{ ...details?.result, ...interaction },
{ mergeWithLastSubmission: merge }
);
};
export const getInteractionStorage = async (ctx: Context, provider: Provider) => {
export const getInteractionStorage = async (
ctx: Context,
provider: Provider
): Promise<AnonymousInteractionResult> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const parseResult = anonymousInteractionResultGuard.safeParse(result);
assertThat(
parseResult.success,
new RequestError({ code: 'session.verification_session_not_found' })
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
return parseResult.data;
@ -115,7 +123,6 @@ export const clearInteractionStorage = async (ctx: Context, provider: Provider)
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
if (result) {
const { event, profile, identifier, ...rest } = result;
await provider.interactionResult(ctx.req, ctx.res, { ...rest });
await provider.interactionResult(ctx.req, ctx.res, {});
}
};

View file

@ -3,18 +3,26 @@ import { SignInIdentifier, SignInMode, Event } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { signInModeValidation, identifierValidation } from './sign-in-experience-validation.js';
import {
verifySignInModeSettings,
verifyIdentifierSettings,
verifyProfileSettings,
} from './sign-in-experience-validation.js';
describe('signInModeValidation', () => {
describe('verifySignInModeSettings', () => {
it(Event.Register, () => {
expect(() => {
signInModeValidation(Event.Register, { signInMode: SignInMode.SignIn } as SignInExperience);
verifySignInModeSettings(Event.Register, {
signInMode: SignInMode.SignIn,
} as SignInExperience);
}).toThrow();
expect(() => {
signInModeValidation(Event.Register, { signInMode: SignInMode.Register } as SignInExperience);
verifySignInModeSettings(Event.Register, {
signInMode: SignInMode.Register,
} as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation(Event.Register, {
verifySignInModeSettings(Event.Register, {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
@ -22,13 +30,15 @@ describe('signInModeValidation', () => {
it('SignIn', () => {
expect(() => {
signInModeValidation(Event.SignIn, { signInMode: SignInMode.SignIn } as SignInExperience);
verifySignInModeSettings(Event.SignIn, { signInMode: SignInMode.SignIn } as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation(Event.SignIn, { signInMode: SignInMode.Register } as SignInExperience);
verifySignInModeSettings(Event.SignIn, {
signInMode: SignInMode.Register,
} as SignInExperience);
}).toThrow();
expect(() => {
signInModeValidation(Event.SignIn, {
verifySignInModeSettings(Event.SignIn, {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
@ -36,17 +46,17 @@ describe('signInModeValidation', () => {
it(Event.ForgotPassword, () => {
expect(() => {
signInModeValidation(Event.ForgotPassword, {
verifySignInModeSettings(Event.ForgotPassword, {
signInMode: SignInMode.SignIn,
} as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation(Event.ForgotPassword, {
verifySignInModeSettings(Event.ForgotPassword, {
signInMode: SignInMode.Register,
} as SignInExperience);
}).not.toThrow();
expect(() => {
signInModeValidation(Event.ForgotPassword, {
verifySignInModeSettings(Event.ForgotPassword, {
signInMode: SignInMode.SignInAndRegister,
} as SignInExperience);
}).not.toThrow();
@ -58,11 +68,11 @@ describe('identifier validation', () => {
const identifier = { username: 'username', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
verifyIdentifierSettings(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
@ -77,11 +87,11 @@ describe('identifier validation', () => {
const identifier = { email: 'email', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
verifyIdentifierSettings(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
@ -92,7 +102,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: [
@ -112,11 +122,11 @@ describe('identifier validation', () => {
const identifier = { email: 'email', passcode: 'passcode' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
verifyIdentifierSettings(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
@ -127,7 +137,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: [
@ -143,7 +153,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email],
@ -168,11 +178,11 @@ describe('identifier validation', () => {
const identifier = { phone: '123', password: 'password' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
verifyIdentifierSettings(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
@ -183,7 +193,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: [
@ -203,11 +213,11 @@ describe('identifier validation', () => {
const identifier = { phone: '123456', passcode: 'passcode' };
expect(() => {
identifierValidation(identifier, mockSignInExperience);
verifyIdentifierSettings(identifier, mockSignInExperience);
}).not.toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: mockSignInExperience.signIn.methods.filter(
@ -218,7 +228,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signIn: {
methods: [
@ -234,7 +244,7 @@ describe('identifier validation', () => {
}).toThrow();
expect(() => {
identifierValidation(identifier, {
verifyIdentifierSettings(identifier, {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Sms],
@ -255,3 +265,81 @@ describe('identifier validation', () => {
}).not.toThrow();
});
});
describe('profile validation', () => {
it('profile sign-in-experience settings verification', () => {
expect(() => {
verifyProfileSettings({ username: 'foo', password: 'password' }, mockSignInExperience);
}).not.toThrow();
expect(() => {
verifyProfileSettings({ email: 'email@logto.io' }, mockSignInExperience);
}).toThrow();
expect(() => {
verifyProfileSettings(
{ email: 'email@logto.io' },
{
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
}
);
}).not.toThrow();
expect(() => {
verifyProfileSettings({ phone: '123456' }, mockSignInExperience);
}).toThrow();
expect(() => {
verifyProfileSettings(
{ phone: '123456' },
{
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
}
);
}).not.toThrow();
expect(() => {
verifyProfileSettings(
{ phone: '123456' },
{
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Sms, SignInIdentifier.Email],
password: false,
verify: true,
},
}
);
}).not.toThrow();
expect(() => {
verifyProfileSettings(
{ email: 'email@logto.io' },
{
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Sms, SignInIdentifier.Email],
password: false,
verify: true,
},
}
);
}).not.toThrow();
expect(() => {
verifyProfileSettings(
{ username: 'foo' },
{
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Sms, SignInIdentifier.Email],
password: false,
verify: true,
},
}
);
}).toThrow();
});
});

View file

@ -1,7 +1,10 @@
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
import { SignInMode, SignInIdentifier, Event } from '@logto/schemas';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
import assertThat from '#src/utils/assert-that.js';
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
@ -11,7 +14,7 @@ const forbiddenIdentifierError = new RequestError({
status: 422,
});
export const signInModeValidation = (event: Event, { signInMode }: SignInExperience) => {
export const verifySignInModeSettings = (event: Event, { signInMode }: SignInExperience) => {
if (event === Event.SignIn) {
assertThat(signInMode !== SignInMode.Register, forbiddenEventError);
}
@ -21,7 +24,7 @@ export const signInModeValidation = (event: Event, { signInMode }: SignInExperie
}
};
export const identifierValidation = (
export const verifyIdentifierSettings = (
identifier: IdentifierPayload,
signInExperience: SignInExperience
) => {
@ -102,7 +105,7 @@ export const identifierValidation = (
// Social Identifier TODO: @darcy, @sijie
};
export const profileValidation = (profile: Profile, { signUp }: SignInExperience) => {
export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperience) => {
if (profile.phone) {
assertThat(signUp.identifiers.includes(SignInIdentifier.Sms), forbiddenIdentifierError);
}
@ -119,3 +122,11 @@ export const profileValidation = (profile: Profile, { signUp }: SignInExperience
assertThat(signUp.password, forbiddenIdentifierError);
}
};
export const getSignInExperience = async (ctx: Context, provider: Provider) => {
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
return getSignInExperienceForApplication(
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
);
};

View file

@ -6,7 +6,7 @@ import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { AnonymousInteractionResult, VerifiedPhoneIdentifier } from '../types/index.js';
import type { AnonymousInteractionResult } from '../types/index.js';
const { jest } = import.meta;
@ -36,6 +36,7 @@ const logContext = createMockLogContext();
describe('identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), ...logContext };
const interactionStorage = { event: Event.SignIn };
afterEach(() => {
jest.clearAllMocks();
@ -49,15 +50,9 @@ describe('identifier verification', () => {
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toThrow();
await expect(
identifierPayloadVerification(baseCtx, createMockProvider(), identifier, interactionStorage)
).rejects.toThrow();
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
expect(verifyUserPassword).toBeCalledWith(null, 'password');
});
@ -65,22 +60,15 @@ describe('identifier verification', () => {
it('username password user is suspended', async () => {
findUserByIdentifier.mockResolvedValueOnce({ id: 'foo' });
verifyUserPassword.mockResolvedValueOnce({ id: 'foo', isSuspended: true });
const identifier = {
username: 'username',
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError(
new RequestError({ code: 'user.suspended', status: 401 })
);
await expect(
identifierPayloadVerification(baseCtx, createMockProvider(), identifier, interactionStorage)
).rejects.toMatchError(new RequestError({ code: 'user.suspended', status: 401 }));
expect(findUserByIdentifier).toBeCalledWith({ username: 'username' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
@ -95,21 +83,15 @@ describe('identifier verification', () => {
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(ctx, createMockProvider());
const result = await identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifier,
interactionStorage
);
expect(findUserByIdentifier).toBeCalledWith({ email: 'email' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({
event: Event.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
});
expect(result).toEqual({ key: 'accountId', value: 'foo' });
});
it('phone password', async () => {
@ -121,83 +103,63 @@ describe('identifier verification', () => {
password: 'password',
};
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(ctx, createMockProvider());
const result = await identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifier,
interactionStorage
);
expect(findUserByIdentifier).toBeCalledWith({ phone: 'phone' });
expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password');
expect(result).toEqual({
event: Event.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
});
expect(result).toEqual({ key: 'accountId', value: 'foo' });
});
it('email passcode', async () => {
const identifier = { email: 'email', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(ctx, createMockProvider());
const result = await identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifier,
interactionStorage
);
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
{ ...identifier, event: interactionStorage.event },
'jti',
logContext.createLog
);
expect(result).toEqual({
event: Event.SignIn,
identifiers: [{ key: 'emailVerified', value: identifier.email }],
});
expect(result).toEqual({ key: 'emailVerified', value: identifier.email });
});
it('phone passcode', async () => {
const identifier = { phone: 'phone', passcode: 'passcode' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifier,
interactionStorage
);
const result = await identifierPayloadVerification(ctx, createMockProvider());
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
{ ...identifier, event: interactionStorage.event },
'jti',
logContext.createLog
);
expect(result).toEqual({
event: Event.SignIn,
identifiers: [{ key: 'phoneVerified', value: identifier.phone }],
});
expect(result).toEqual({ key: 'phoneVerified', value: identifier.phone });
});
it('social', async () => {
const identifier = { connectorId: 'logto', connectorData: {} };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(ctx, createMockProvider());
const result = await identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifier,
interactionStorage
);
expect(verifySocialIdentity).toBeCalledWith(
identifier,
@ -207,10 +169,9 @@ describe('identifier verification', () => {
expect(findUserByIdentifier).not.toBeCalled();
expect(result).toEqual({
event: Event.SignIn,
identifiers: [
{ key: 'social', connectorId: identifier.connectorId, userInfo: { id: 'foo' } },
],
key: 'social',
connectorId: identifier.connectorId,
userInfo: { id: 'foo' },
});
});
@ -229,54 +190,31 @@ describe('identifier verification', () => {
],
};
const identifierPayload = { connectorId: 'logto', identityType: 'email' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier: identifierPayload,
}),
};
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
const result = await identifierPayloadVerification(
ctx,
baseCtx,
createMockProvider(),
identifierPayload,
interactionRecord
);
expect(result).toEqual({
event: Event.SignIn,
identifiers: [
{
key: 'social',
connectorId: 'logto',
userInfo: {
id: 'foo',
email: 'email@logto.io',
},
},
{
key: 'emailVerified',
value: 'email@logto.io',
},
],
key: 'emailVerified',
value: 'email@logto.io',
});
});
it('verified social email should throw if social session not found', async () => {
const identifierPayload = { connectorId: 'logto', identityType: 'email' };
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier: identifierPayload,
}),
};
await expect(identifierPayloadVerification(ctx, createMockProvider())).rejects.toMatchError(
new RequestError('session.connector_session_not_found')
);
await expect(
identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifierPayload,
interactionStorage
)
).rejects.toMatchError(new RequestError('session.connector_session_not_found'));
});
it('verified social email should throw if social identity not found', async () => {
@ -293,47 +231,15 @@ describe('identifier verification', () => {
],
};
const identifierPayload = { connectorId: 'logto', identityType: 'email' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier: identifierPayload,
}),
};
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
await expect(
identifierPayloadVerification(ctx, createMockProvider(), interactionRecord)
identifierPayloadVerification(
baseCtx,
createMockProvider(),
identifierPayload,
interactionRecord
)
).rejects.toMatchError(new RequestError('session.connector_session_not_found'));
});
it('should merge identifier if exist', async () => {
const identifier = { email: 'email', passcode: 'passcode' };
const oldIdentifier: VerifiedPhoneIdentifier = { key: 'phoneVerified', value: '123456' };
const ctx = {
...baseCtx,
interactionPayload: Object.freeze({
event: Event.SignIn,
identifier,
}),
};
const result = await identifierPayloadVerification(ctx, createMockProvider(), {
event: Event.Register,
identifiers: [oldIdentifier],
});
expect(verifyIdentifierByPasscode).toBeCalledWith(
{ ...identifier, event: Event.SignIn },
'jti',
logContext.createLog
);
expect(result).toEqual({
event: Event.SignIn,
identifiers: [oldIdentifier, { key: 'emailVerified', value: identifier.email }],
});
});
});

View file

@ -1,5 +1,11 @@
import type { GetSession } from '@logto/connector-kit';
import type { Event, SocialConnectorPayload, SocialIdentityPayload } from '@logto/schemas';
import type {
Event,
IdentifierPayload,
SocialConnectorPayload,
SocialIdentityPayload,
} from '@logto/schemas';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
@ -10,25 +16,21 @@ import assertThat from '#src/utils/assert-that.js';
import type {
PasswordIdentifierPayload,
PasscodeIdentifierPayload,
InteractionContext,
SocialIdentifier,
VerifiedEmailIdentifier,
VerifiedPhoneIdentifier,
AnonymousInteractionResult,
PayloadVerifiedInteractionResult,
Identifier,
AccountIdIdentifier,
} from '../types/index.js';
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import { isPasscodeIdentifier, isPasswordIdentifier, isSocialIdentifier } from '../utils/index.js';
import { mergeIdentifiers, storeInteractionResult } from '../utils/interaction.js';
import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js';
import { verifySocialIdentity } from '../utils/social-verification.js';
const verifyPasswordIdentifier = async (
identifier: PasswordIdentifierPayload
): Promise<AccountIdIdentifier> => {
// TODO: Log
const { password, ...identity } = identifier;
const user = await findUserByIdentifier(identity);
const verifiedUser = await verifyUserPassword(user, password);
@ -43,7 +45,7 @@ const verifyPasswordIdentifier = async (
const verifyPasscodeIdentifier = async (
event: Event,
identifier: PasscodeIdentifierPayload,
ctx: InteractionContext,
ctx: Context,
provider: Provider
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
@ -57,7 +59,7 @@ const verifyPasscodeIdentifier = async (
const verifySocialIdentifier = async (
identifier: SocialConnectorPayload,
ctx: InteractionContext,
ctx: Context,
getSession?: GetSession
): Promise<SocialIdentifier> => {
const userInfo = await verifySocialIdentity(identifier, ctx.createLog, getSession);
@ -85,54 +87,28 @@ const verifySocialIdentityInInteractionRecord = async (
};
};
const verifyIdentifierPayload = async (
ctx: InteractionContext,
export default async function identifierPayloadVerification(
ctx: Context,
provider: Provider,
interactionRecord?: AnonymousInteractionResult
): Promise<Identifier | undefined> => {
const { identifier, event } = ctx.interactionPayload;
identifierPayload: IdentifierPayload,
interactionStorage: AnonymousInteractionResult
): Promise<Identifier> {
const { event } = interactionStorage;
// No Identifier in payload
if (!identifier) {
return;
if (isPasswordIdentifier(identifierPayload)) {
return verifyPasswordIdentifier(identifierPayload);
}
if (isPasswordIdentifier(identifier)) {
return verifyPasswordIdentifier(identifier);
if (isPasscodeIdentifier(identifierPayload)) {
return verifyPasscodeIdentifier(event, identifierPayload, ctx, provider);
}
if (isPasscodeIdentifier(identifier)) {
return verifyPasscodeIdentifier(event, identifier, ctx, provider);
}
if (isSocialIdentifier(identifier)) {
return verifySocialIdentifier(identifier, ctx, async () =>
if (isSocialIdentifier(identifierPayload)) {
return verifySocialIdentifier(identifierPayload, ctx, async () =>
getConnectorSessionResult(ctx, provider)
);
}
// Sign-In with social verified email or phone
return verifySocialIdentityInInteractionRecord(identifier, interactionRecord);
};
export default async function identifierPayloadVerification(
ctx: InteractionContext,
provider: Provider,
interactionRecord?: AnonymousInteractionResult
): Promise<PayloadVerifiedInteractionResult> {
const { event } = ctx.interactionPayload;
const identifier = await verifyIdentifierPayload(ctx, provider, interactionRecord);
const interaction: PayloadVerifiedInteractionResult = {
...interactionRecord,
event,
identifiers: identifier
? mergeIdentifiers([identifier], interactionRecord?.identifiers)
: interactionRecord?.identifiers,
};
await storeInteractionResult(interaction, ctx, provider);
return interaction;
return verifySocialIdentityInInteractionRecord(identifierPayload, interactionStorage);
}

View file

@ -0,0 +1,61 @@
import { Event } from '@logto/schemas';
import { mockEsmWithActual, mockEsmDefault, pickDefault } from '@logto/shared/esm';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { SignInInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { storeInteractionResult } = await mockEsmWithActual('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const verifyUserAccount = mockEsmDefault('./user-identity-verification.js', () => jest.fn());
const verifyIdentifier = await pickDefault(import('./identifier-verification.js'));
describe('verifyIdentifier', () => {
const ctx = {
...createContextWithRouteParameters(),
...createMockLogContext(),
};
const provider = createMockProvider();
afterEach(() => {
jest.clearAllMocks();
});
it('should return the interaction record if the event is register', async () => {
const interactionRecord = {
event: Event.Register,
};
const result = await verifyIdentifier(ctx, provider, interactionRecord);
expect(result).toBe(interactionRecord);
expect(verifyUserAccount).not.toBeCalled();
expect(storeInteractionResult).not.toBeCalled();
});
it('should return and assign the verified result to the interaction record if the event is sign in', async () => {
const interactionRecord: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'emailVerified', value: 'email@logto.io' }],
};
const verifiedRecord = {
...interactionRecord,
accountId: 'foo',
};
verifyUserAccount.mockResolvedValue(verifiedRecord);
const result = await verifyIdentifier(ctx, provider, interactionRecord);
expect(result).toBe(verifiedRecord);
expect(verifyUserAccount).toBeCalledWith(interactionRecord);
expect(storeInteractionResult).toBeCalledWith(verifiedRecord, ctx, provider);
});
});

View file

@ -1,24 +1,33 @@
import { Event } from '@logto/schemas';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import type {
InteractionContext,
AnonymousInteractionResult,
IdentifierVerifiedInteractionResult,
RegisterInteractionResult,
SignInInteractionResult,
ForgotPasswordInteractionResult,
AccountVerifiedInteractionResult,
} from '../types/index.js';
import identifierPayloadVerification from './identifier-payload-verification.js';
import userAccountVerification from './user-identity-verification.js';
import { storeInteractionResult } from '../utils/interaction.js';
import verifyUserAccount from './user-identity-verification.js';
type InteractionResult =
| RegisterInteractionResult
| SignInInteractionResult
| ForgotPasswordInteractionResult;
export default async function verifyIdentifier(
ctx: InteractionContext,
ctx: Context,
provider: Provider,
interactionRecord?: AnonymousInteractionResult
): Promise<IdentifierVerifiedInteractionResult> {
const verifiedInteraction = await identifierPayloadVerification(ctx, provider, interactionRecord);
if (verifiedInteraction.event === Event.Register) {
return verifiedInteraction;
interactionRecord: InteractionResult
): Promise<RegisterInteractionResult | AccountVerifiedInteractionResult> {
if (interactionRecord.event === Event.Register) {
return interactionRecord;
}
return userAccountVerification(verifiedInteraction, ctx, provider);
// Verify the user account and assign the verified result to the interaction record
const accountVerifiedInteractionResult = await verifyUserAccount(interactionRecord);
await storeInteractionResult(accountVerifiedInteractionResult, ctx, provider);
return accountVerifiedInteractionResult;
}

View file

@ -1,3 +1,4 @@
export { default as verifyProfile } from './profile-verification.js';
export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js';
export { default as verifyIdentifierPayload } from './identifier-payload-verification.js';
export { default as verifyIdentifier } from './identifier-verification.js';

View file

@ -3,6 +3,7 @@ import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
@ -17,11 +18,16 @@ const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn(),
}));
const { getSignInExperience } = mockEsm('../utils/sign-in-experience-validation.js', () => ({
getSignInExperience: jest.fn().mockReturnValue(mockSignInExperience),
}));
const validateMandatoryUserProfile = await pickDefault(
import('./mandatory-user-profile-validation.js')
);
describe('validateMandatoryUserProfile', () => {
const provider = createMockProvider();
const baseCtx = createContextWithRouteParameters();
const interaction: IdentifierVerifiedInteractionResult = {
event: Event.SignIn,
@ -29,12 +35,7 @@ describe('validateMandatoryUserProfile', () => {
};
it('username and password missing but required', async () => {
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
};
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
@ -42,7 +43,7 @@ describe('validateMandatoryUserProfile', () => {
);
await expect(
validateMandatoryUserProfile(ctx, {
validateMandatoryUserProfile(baseCtx, provider, {
...interaction,
profile: {
username: 'username',
@ -58,24 +59,18 @@ describe('validateMandatoryUserProfile', () => {
});
isUserPasswordSet.mockResolvedValueOnce(true);
const ctx = {
...baseCtx,
signInExperience: mockSignInExperience,
};
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
await expect(
validateMandatoryUserProfile(baseCtx, provider, interaction)
).resolves.not.toThrow();
});
it('email missing but required', async () => {
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
},
};
getSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
});
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.email] }
@ -88,27 +83,23 @@ describe('validateMandatoryUserProfile', () => {
primaryEmail: 'email',
});
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
},
};
getSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
});
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
await expect(
validateMandatoryUserProfile(baseCtx, provider, interaction)
).resolves.not.toThrow();
});
it('phone missing but required', async () => {
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
},
};
getSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
});
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.phone] }
@ -121,31 +112,27 @@ describe('validateMandatoryUserProfile', () => {
primaryPhone: 'phone',
});
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
},
};
getSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
});
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
await expect(
validateMandatoryUserProfile(baseCtx, provider, interaction)
).resolves.not.toThrow();
});
it('email or Phone required', async () => {
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
password: false,
verify: true,
},
getSignInExperience.mockResolvedValue({
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
password: false,
verify: true,
},
};
});
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.emailOrPhone] }
@ -153,11 +140,17 @@ describe('validateMandatoryUserProfile', () => {
);
await expect(
validateMandatoryUserProfile(ctx, { ...interaction, profile: { email: 'email' } })
validateMandatoryUserProfile(baseCtx, provider, {
...interaction,
profile: { email: 'email' },
})
).resolves.not.toThrow();
await expect(
validateMandatoryUserProfile(ctx, { ...interaction, profile: { phone: '123456' } })
validateMandatoryUserProfile(baseCtx, provider, {
...interaction,
profile: { phone: '123456' },
})
).resolves.not.toThrow();
});
});

View file

@ -2,14 +2,15 @@ import type { Profile, SignInExperience, User } from '@logto/schemas';
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { Context } from 'koa';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { findUserById } from '#src/queries/user.js';
import assertThat from '#src/utils/assert-that.js';
import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
import { isUserPasswordSet } from '../utils/index.js';
import { getSignInExperience } from '../utils/sign-in-experience-validation.js';
// eslint-disable-next-line complexity
const getMissingProfileBySignUpIdentifiers = ({
@ -70,12 +71,11 @@ const getMissingProfileBySignUpIdentifiers = ({
};
export default async function validateMandatoryUserProfile(
ctx: WithSignInExperienceContext<Context>,
ctx: Context,
provider: Provider,
interaction: IdentifierVerifiedInteractionResult
) {
const {
signInExperience: { signUp },
} = ctx;
const { signUp } = await getSignInExperience(ctx, provider);
const { event, accountId, profile } = interaction;
const user = event === Event.Register ? null : await findUserById(accountId);

View file

@ -2,18 +2,9 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { InteractionContext } from '../types/index.js';
const { jest } = import.meta;
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' }),
}));
@ -25,44 +16,30 @@ const { argon2Verify } = mockEsm('hash-wasm', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('forgot password interaction profile verification', () => {
const provider = createMockProvider();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = {
const baseInteraction = {
event: Event.ForgotPassword,
accountId: 'foo',
};
it('missing profile', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.ForgotPassword,
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(baseInteraction)).rejects.toMatchError(
new RequestError({
code: 'user.new_password_required_in_profile',
status: 422,
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('same password', async () => {
argon2Verify.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.ForgotPassword,
profile: {
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
password: 'password',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.same_password',
status: 422,
@ -70,23 +47,18 @@ describe('forgot password interaction profile verification', () => {
);
expect(findUserById).toBeCalledWith(interaction.accountId);
expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' });
expect(storeInteractionResult).not.toBeCalled();
});
it('proper set password', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.ForgotPassword,
profile: {
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
password: 'password',
},
};
await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow();
await expect(verifyProfile(interaction)).resolves.not.toThrow();
expect(findUserById).toBeCalledWith(interaction.accountId);
expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' });
expect(storeInteractionResult).toBeCalled();
});
});

View file

@ -2,15 +2,8 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
Identifier,
IdentifierVerifiedInteractionResult,
InteractionContext,
} from '../types/index.js';
import type { Identifier, IdentifierVerifiedInteractionResult } from '../types/index.js';
const { jest } = import.meta;
@ -21,10 +14,6 @@ const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => (
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
}));
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn().mockResolvedValueOnce(true),
}));
@ -32,15 +21,13 @@ mockEsm('../utils/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('Should throw when providing existing identifiers in profile', () => {
const provider = createMockProvider();
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email' },
{ key: 'phoneVerified', value: 'phone' },
{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } },
];
const interaction: IdentifierVerifiedInteractionResult = {
const baseInteraction: IdentifierVerifiedInteractionResult = {
event: Event.SignIn,
accountId: 'foo',
identifiers,
@ -53,84 +40,68 @@ describe('Should throw when providing existing identifiers in profile', () => {
it('username exists', async () => {
findUserById.mockResolvedValueOnce({ id: 'foo', username: 'foo' });
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
username: 'username',
},
const interaction = {
...baseInteraction,
profile: {
username: 'username',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.username_exists_in_profile',
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('email exists', async () => {
findUserById.mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' });
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
email: 'email',
},
const interaction = {
...baseInteraction,
profile: {
email: 'email',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.email_exists_in_profile',
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('phone exists', async () => {
findUserById.mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' });
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
phone: 'phone',
},
const interaction = {
...baseInteraction,
profile: {
phone: 'phone',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.phone_exists_in_profile',
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('password exists', async () => {
findUserById.mockResolvedValueOnce({ id: 'foo' });
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
password: 'password',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.password_exists_in_profile',
})
);
expect(storeInteractionResult).not.toBeCalled();
});
});

View file

@ -2,22 +2,11 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type {
Identifier,
InteractionContext,
IdentifierVerifiedInteractionResult,
} from '../types/index.js';
import type { Identifier, IdentifierVerifiedInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } =
await mockEsmWithActual('#src/queries/user.js', () => ({
hasUser: jest.fn().mockResolvedValue(false),
@ -35,16 +24,14 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'emailVerified', value: 'email@logto.io' },
{ key: 'phoneVerified', value: '123456' },
{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } },
];
const provider = createMockProvider();
const interaction: IdentifierVerifiedInteractionResult = {
const baseInteraction: IdentifierVerifiedInteractionResult = {
event: Event.Register,
identifiers,
};
@ -55,79 +42,61 @@ describe('register payload guard', () => {
});
it('username only should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
username: 'username',
},
const interaction = {
...baseInteraction,
profile: {
username: 'username',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow();
expect(storeInteractionResult).not.toBeCalled();
await expect(verifyProfile(interaction)).rejects.toThrow();
});
it('password only should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
password: 'password',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow();
expect(storeInteractionResult).not.toBeCalled();
await expect(verifyProfile(interaction)).rejects.toThrow();
});
it('username password is valid', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
username: 'username',
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
username: 'username',
password: 'password',
},
};
const result = await verifyProfile(ctx, provider, interaction);
expect(result).toEqual({ ...interaction, profile: ctx.interactionPayload.profile });
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
it('username with a given email is valid', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
username: 'username',
email: 'email@logto.io',
},
const interaction = {
...baseInteraction,
profile: {
username: 'username',
email: 'email@logto.io',
},
};
await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow();
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
it('password with a given email is valid', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
password: 'password',
email: 'email@logto.io',
},
const interaction = {
...baseInteraction,
profile: {
password: 'password',
email: 'email@logto.io',
},
};
await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow();
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
});
@ -135,89 +104,71 @@ describe('profile registered validation', () => {
it('username is registered', async () => {
hasUser.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
username: 'username',
password: 'password',
},
const interaction = {
...baseInteraction,
profile: {
username: 'username',
password: 'password',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.username_already_in_use',
status: 422,
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('email is registered', async () => {
hasUserWithEmail.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
email: 'email@logto.io',
},
const interaction = {
...baseInteraction,
profile: {
email: 'email@logto.io',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.email_already_in_use',
status: 422,
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('phone is registered', async () => {
hasUserWithPhone.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
phone: '123456',
},
const interaction = {
...baseInteraction,
profile: {
phone: '123456',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.phone_already_in_use',
status: 422,
})
);
expect(storeInteractionResult).not.toBeCalled();
});
it('connector identity exist', async () => {
hasUserWithIdentity.mockResolvedValueOnce(true);
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.Register,
profile: {
connectorId: 'connectorId',
},
const interaction = {
...baseInteraction,
profile: {
connectorId: 'connectorId',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
expect(storeInteractionResult).not.toBeCalled();
});
});

View file

@ -2,18 +2,11 @@ import { Event } from '@logto/schemas';
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { Identifier, InteractionContext } from '../types/index.js';
import type { Identifier } from '../types/index.js';
const { jest } = import.meta;
const { storeInteractionResult } = mockEsm('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
await mockEsmWithActual('#src/queries/user.js', () => ({
findUserById: jest.fn().mockResolvedValue({ id: 'foo' }),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
@ -30,9 +23,7 @@ mockEsm('#src/connectors/index.js', () => ({
const verifyProfile = await pickDefault(import('./profile-verification.js'));
describe('profile protected identifier verification', () => {
const baseCtx = { ...createContextWithRouteParameters(), ...createMockLogContext() };
const interaction = { event: Event.SignIn, accountId: 'foo' };
const provider = createMockProvider();
const baseInteraction = { event: Event.SignIn, accountId: 'foo' };
afterEach(() => {
jest.clearAllMocks();
@ -40,202 +31,134 @@ describe('profile protected identifier verification', () => {
describe('email, phone and social identifier must be verified', () => {
it('email without a verified identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
email: 'email',
},
const interaction = {
...baseInteraction,
profile: {
email: 'email',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('email with unmatched identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
email: 'email',
},
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'phone' }];
const interaction = {
...baseInteraction,
identifiers,
profile: {
email: 'email',
},
};
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'phone' }];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('email with proper identifier should not throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
email: 'email',
},
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'email' }];
const interaction = {
...baseInteraction,
identifiers,
profile: {
email: 'email',
},
};
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'email' }];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).resolves.not.toThrow();
expect(storeInteractionResult).toBeCalledWith(
{
...interaction,
identifiers,
profile: ctx.interactionPayload.profile,
},
ctx,
provider
);
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
it('phone without a verified identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
phone: 'phone',
},
const interaction = {
...baseInteraction,
profile: {
phone: 'phone',
},
};
await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('phone with unmatched identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
phone: 'phone',
},
const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'email' }];
const interaction = {
...baseInteraction,
identifiers,
profile: {
phone: 'phone',
},
};
const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'email' }];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('phone with proper identifier should not throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
phone: 'phone',
},
const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'phone' }];
const interaction = {
...baseInteraction,
identifiers,
profile: {
phone: 'phone',
},
};
const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'phone' }];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).resolves.not.toThrow();
expect(storeInteractionResult).toBeCalledWith(
{
...interaction,
identifiers,
profile: ctx.interactionPayload.profile,
},
ctx,
provider
);
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
it('connectorId without a verified identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
connectorId: 'connectorId',
},
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'foo@logto.io' }];
const interaction = {
...baseInteraction,
identifiers,
profile: {
connectorId: 'connectorId',
},
};
const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'foo@logto.io' }];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).rejects.toMatchError(
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('connectorId with unmatched identifier should throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
connectorId: 'logto',
},
},
};
const identifiers: Identifier[] = [
{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } },
];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).rejects.toMatchError(
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('connectorId with proper identifier should not throw', async () => {
const ctx: InteractionContext = {
...baseCtx,
interactionPayload: {
event: Event.SignIn,
profile: {
connectorId: 'logto',
},
const interaction = {
...baseInteraction,
identifiers,
profile: {
connectorId: 'logto',
},
};
await expect(verifyProfile(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.connector_session_not_found', status: 404 })
);
});
it('connectorId with proper identifier should not throw', async () => {
const identifiers: Identifier[] = [
{ key: 'accountId', value: 'foo' },
{ key: 'social', connectorId: 'logto', userInfo: { id: 'foo' } },
];
await expect(
verifyProfile(ctx, provider, { ...interaction, identifiers })
).resolves.not.toThrow();
expect(storeInteractionResult).toBeCalledWith(
{
...interaction,
identifiers,
profile: ctx.interactionPayload.profile,
const interaction = {
...baseInteraction,
identifiers,
profile: {
connectorId: 'logto',
},
ctx,
provider
);
};
await expect(verifyProfile(interaction)).resolves.not.toThrow();
});
});
});

View file

@ -1,7 +1,6 @@
import type { Profile, User } from '@logto/schemas';
import { Event } from '@logto/schemas';
import { argon2Verify } from 'hash-wasm';
import type { Provider } from 'oidc-provider';
import { getLogtoConnectorById } from '#src/connectors/index.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -16,18 +15,15 @@ import assertThat from '#src/utils/assert-that.js';
import { registerProfileSafeGuard, forgotPasswordProfileGuard } from '../types/guard.js';
import type {
InteractionContext,
Identifier,
SocialIdentifier,
IdentifierVerifiedInteractionResult,
VerifiedInteractionResult,
VerifiedRegisterInteractionResult,
RegisterSafeProfile,
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
RegisterInteractionResult,
VerifiedRegisterInteractionResult,
} from '../types/index.js';
import { isUserPasswordSet } from '../utils/index.js';
import { storeInteractionResult } from '../utils/interaction.js';
const verifyProfileIdentifiers = (
{ email, phone, connectorId }: Profile,
@ -169,29 +165,27 @@ const verifyProfileNotExistInCurrentUserAccount = async (
}
};
const isValidRegisterProfile = (profile: Profile): profile is RegisterSafeProfile =>
registerProfileSafeGuard.safeParse(profile).success;
const isValidRegisterInteractionResult = (
interaction: RegisterInteractionResult
): interaction is VerifiedRegisterInteractionResult =>
registerProfileSafeGuard.safeParse(interaction.profile).success;
export default async function verifyProfile(
ctx: InteractionContext,
provider: Provider,
interaction: IdentifierVerifiedInteractionResult
): Promise<VerifiedInteractionResult> {
const profile = { ...interaction.profile, ...ctx.interactionPayload.profile };
const { event, identifiers, accountId } = interaction;
const { event, identifiers, accountId, profile = {} } = interaction;
if (event === Event.Register) {
// Verify the profile includes sufficient identifiers to register a new account
assertThat(isValidRegisterProfile(profile), new RequestError({ code: 'guard.invalid_input' }));
assertThat(
isValidRegisterInteractionResult(interaction),
new RequestError({ code: 'guard.invalid_input' })
);
verifyProfileIdentifiers(profile, identifiers);
await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers);
const interactionWithProfile: VerifiedRegisterInteractionResult = { ...interaction, profile };
await storeInteractionResult(interactionWithProfile, ctx, provider);
return interactionWithProfile;
return interaction;
}
if (event === Event.SignIn) {
@ -201,10 +195,7 @@ export default async function verifyProfile(
await verifyProfileNotExistInCurrentUserAccount(profile, user);
await verifyProfileNotRegisteredByOtherUserAccount(profile, identifiers);
const interactionWithProfile: VerifiedSignInInteractionResult = { ...interaction, profile };
await storeInteractionResult(interactionWithProfile, ctx, provider);
return interactionWithProfile;
return interaction;
}
// Forgot Password
@ -228,7 +219,6 @@ export default async function verifyProfile(
...interaction,
profile: passwordProfile,
};
await storeInteractionResult(interactionWithProfile, ctx, provider);
return interactionWithProfile;
}

View file

@ -1,12 +1,9 @@
import { Event } from '@logto/schemas';
import { mockEsm, mockEsmDefault, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
import { mockEsm, mockEsmDefault, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { InteractionContext, PayloadVerifiedInteractionResult } from '../types/index.js';
import type { SignInInteractionResult } from '../types/index.js';
const { jest } = import.meta;
@ -16,74 +13,57 @@ mockEsm('#src/libraries/social.js', () => ({
findSocialRelatedUser: jest.fn().mockResolvedValue(null),
}));
const { storeInteractionResult } = await mockEsmWithActual('../utils/interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
const verifyUserAccount = await pickDefault(import('./user-identity-verification.js'));
const userAccountVerification = await pickDefault(import('./user-identity-verification.js'));
describe('userAccountVerification', () => {
describe('verifyUserAccount', () => {
const findUserByIdentifierMock = findUserByIdentifier;
const ctx: InteractionContext = {
...createContextWithRouteParameters(),
...createMockLogContext(),
interactionPayload: {
event: Event.SignIn,
},
};
const provider = createMockProvider();
afterEach(() => {
jest.clearAllMocks();
});
it('empty identifiers with accountId', async () => {
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
accountId: 'foo',
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(storeInteractionResult).not.toBeCalled();
expect(result).toEqual(result);
});
it('empty identifiers withOut accountId should throw', async () => {
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError({ code: 'session.identifier_not_found', status: 404 })
);
expect(storeInteractionResult).not.toBeCalled();
});
it('verify accountId identifier', async () => {
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(storeInteractionResult).toBeCalledWith(result, ctx, provider);
expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] });
});
it('verify emailVerified identifier', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(storeInteractionResult).toBeCalledWith(result, ctx, provider);
expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] });
});
@ -91,14 +71,13 @@ describe('userAccountVerification', () => {
it('verify phoneVerified identifier', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'phoneVerified', value: '123456' }],
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(findUserByIdentifierMock).toBeCalledWith({ phone: '123456' });
expect(storeInteractionResult).toBeCalledWith(result, ctx, provider);
expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] });
});
@ -106,17 +85,16 @@ describe('userAccountVerification', () => {
it('verify social identifier', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(findUserByIdentifierMock).toBeCalledWith({
connectorId: 'connectorId',
userInfo: { id: 'foo' },
});
expect(storeInteractionResult).toBeCalledWith(result, ctx, provider);
expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] });
});
@ -124,12 +102,12 @@ describe('userAccountVerification', () => {
it('verify social identifier user identity not exist', async () => {
findUserByIdentifierMock.mockResolvedValueOnce(null);
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError(
{
code: 'user.identity_not_exist',
@ -143,14 +121,12 @@ describe('userAccountVerification', () => {
connectorId: 'connectorId',
userInfo: { id: 'foo' },
});
expect(storeInteractionResult).not.toBeCalled();
});
it('verify accountId and emailVerified identifier with same user', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [
{ key: 'accountId', value: 'foo' },
@ -158,16 +134,16 @@ describe('userAccountVerification', () => {
],
};
const result = await userAccountVerification(interaction, ctx, provider);
const result = await verifyUserAccount(interaction);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(storeInteractionResult).toBeCalledWith(result, ctx, provider);
expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] });
});
it('verify accountId and emailVerified identifier with email user not exist', async () => {
findUserByIdentifierMock.mockResolvedValueOnce(null);
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [
{ key: 'accountId', value: 'foo' },
@ -175,12 +151,11 @@ describe('userAccountVerification', () => {
],
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' })
);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(storeInteractionResult).not.toBeCalled();
});
it('verify phoneVerified and emailVerified identifier with email user suspend', async () => {
@ -188,7 +163,7 @@ describe('userAccountVerification', () => {
.mockResolvedValueOnce({ id: 'foo' })
.mockResolvedValueOnce({ id: 'foo2', isSuspended: true });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [
{ key: 'emailVerified', value: 'email' },
@ -196,13 +171,12 @@ describe('userAccountVerification', () => {
],
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError({ code: 'user.suspended', status: 401 })
);
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' });
expect(storeInteractionResult).not.toBeCalled();
});
it('verify phoneVerified and emailVerified identifier returns inconsistent id', async () => {
@ -210,7 +184,7 @@ describe('userAccountVerification', () => {
.mockResolvedValueOnce({ id: 'foo' })
.mockResolvedValueOnce({ id: 'foo2' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [
{ key: 'emailVerified', value: 'email' },
@ -218,34 +192,32 @@ describe('userAccountVerification', () => {
],
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' });
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' });
expect(storeInteractionResult).not.toBeCalled();
});
it('verify emailVerified identifier returns inconsistent id with existing accountId', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
accountId: 'foo2',
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError(
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(storeInteractionResult).not.toBeCalled();
});
it('profile use identifier should remain', async () => {
findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' });
const interaction: PayloadVerifiedInteractionResult = {
const interaction: SignInInteractionResult = {
event: Event.SignIn,
identifiers: [
{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } },
@ -254,22 +226,13 @@ describe('userAccountVerification', () => {
],
profile: {
phone: '123456',
connectorId: 'connectorId',
},
};
const ctxWithSocialProfile: InteractionContext = {
...ctx,
interactionPayload: {
event: Event.SignIn,
profile: {
connectorId: 'connectorId',
},
},
};
const result = await userAccountVerification(interaction, ctxWithSocialProfile, provider);
const result = await verifyUserAccount(interaction);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(storeInteractionResult).toBeCalledWith(result, ctxWithSocialProfile, provider);
expect(result).toEqual({
event: Event.SignIn,
accountId: 'foo',
@ -279,6 +242,7 @@ describe('userAccountVerification', () => {
],
profile: {
phone: '123456',
connectorId: 'connectorId',
},
});
});

View file

@ -1,5 +1,4 @@
import { deduplicate } from '@silverhand/essentials';
import type { Provider } from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { findSocialRelatedUser } from '#src/libraries/social.js';
@ -10,17 +9,13 @@ import type {
SocialIdentifier,
VerifiedEmailIdentifier,
VerifiedPhoneIdentifier,
PreAccountVerifiedInteractionResult,
SignInInteractionResult,
ForgotPasswordInteractionResult,
AccountVerifiedInteractionResult,
Identifier,
InteractionContext,
} from '../types/index.js';
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import {
storeInteractionResult,
isAccountVerifiedInteractionResult,
categorizeIdentifiers,
} from '../utils/interaction.js';
import { isAccountVerifiedInteractionResult, categorizeIdentifiers } from '../utils/interaction.js';
const identifyUserByVerifiedEmailOrPhone = async (
identifier: VerifiedEmailIdentifier | VerifiedPhoneIdentifier
@ -77,17 +72,14 @@ const identifyUser = async (identifier: Identifier) => {
return identifyUserByVerifiedEmailOrPhone(identifier);
};
export default async function userAccountVerification(
interaction: PreAccountVerifiedInteractionResult,
ctx: InteractionContext,
provider: Provider
export default async function verifyUserAccount(
interaction: SignInInteractionResult | ForgotPasswordInteractionResult
): Promise<AccountVerifiedInteractionResult> {
const { identifiers = [], accountId, profile } = interaction;
const { userAccountIdentifiers, profileIdentifiers } = categorizeIdentifiers(
identifiers,
// Need to merge the profile in payload
{ ...profile, ...ctx.interactionPayload.profile }
profile
);
// Return the interaction directly if it is accountVerified and has no unverified userAccountIdentifiers
@ -100,7 +92,7 @@ export default async function userAccountVerification(
assertThat(
userAccountIdentifiers.length > 0,
new RequestError({
code: 'session.verification_session_not_found',
code: 'session.identifier_not_found',
status: 404,
})
);
@ -120,14 +112,10 @@ export default async function userAccountVerification(
new RequestError('session.verification_failed')
);
// Assign the verification result and store the profile identifiers left
const verifiedInteraction: AccountVerifiedInteractionResult = {
// Return the verified interaction and remove the consumed userAccountIdentifiers
return {
...interaction,
identifiers: profileIdentifiers,
accountId: deduplicateAccountIds[0],
};
await storeInteractionResult(verifiedInteraction, ctx, provider);
return verifiedInteraction;
}

View file

@ -12,22 +12,49 @@ export type interactionPayload = {
profile?: Profile;
};
export const putInteraction = async (payload: interactionPayload, cookie: string) =>
export const putInteraction = async (cookie: string, payload: interactionPayload) =>
api
.put('interaction', {
headers: { cookie },
json: payload,
followRedirect: false,
})
.json<RedirectResponse>();
.json();
export const patchInteraction = async (payload: interactionPayload, cookie: string) =>
export const putInteractionEvent = async (cookie: string, payload: { event: Event }) =>
api
.patch('interaction', {
.put('interaction/event', { headers: { cookie }, json: payload, followRedirect: false })
.json();
export const patchInteractionIdentifiers = async (cookie: string, payload: IdentifierPayload) =>
api
.patch('interaction/identifiers', {
headers: { cookie },
json: payload,
followRedirect: false,
})
.json();
export const patchInteractionProfile = async (cookie: string, payload: Profile) =>
api
.patch('interaction/profile', {
headers: { cookie },
json: payload,
followRedirect: false,
})
.json();
export const deleteInteractionProfile = async (cookie: string) =>
api
.delete('interaction/profile', {
headers: { cookie },
followRedirect: false,
})
.json();
export const submitInteraction = async (cookie: string) =>
api
.post('interaction/submit', { headers: { cookie }, followRedirect: false })
.json<RedirectResponse>();
export type VerificationPasscodePayload =
@ -38,10 +65,26 @@ export type VerificationPasscodePayload =
| { event: Event; phone: string };
export const sendVerificationPasscode = async (
payload: VerificationPasscodePayload,
cookie: string
cookie: string,
payload: VerificationPasscodePayload
) =>
api.post('verification/passcode', {
api.post('interaction/verification/passcode', {
headers: { cookie },
json: payload,
followRedirect: false,
});
export type SocialAuthorizationUriPayload = {
connectorId: string;
state: string;
redirectUri: string;
};
export const createSocialAuthorizationUri = async (
cookie: string,
payload: SocialAuthorizationUriPayload
) =>
api.post('interaction/verification/social-authorization-uri', {
headers: { cookie },
json: payload,
followRedirect: false,

View file

@ -6,6 +6,7 @@ import { assert } from '@silverhand/essentials';
import { got } from 'got';
import { consent } from '#src/api/index.js';
import { submitInteraction } from '#src/api/interaction.js';
import { demoAppRedirectUri, logtoUrl } from '#src/constants.js';
import { MemoryStorage } from './storage.js';
@ -15,7 +16,6 @@ export const defaultConfig = {
appId: demoAppApplicationId,
persistAccessToken: false,
};
export default class MockClient {
public rawCookies: string[] = [];
@ -135,6 +135,24 @@ export default class MockClient {
this.rawCookies = cookie.split(';').map((value) => value.trim());
}
public async send<Args extends unknown[], T>(
api: (cookie: string, ...args: Args) => Promise<T>,
...payload: Args
) {
return api(this.interactionCookie, ...payload);
}
public async successSend<Args extends unknown[], T>(
api: (cookie: string, ...args: Args) => Promise<T>,
...payload: Args
) {
return expect(api(this.interactionCookie, ...payload)).resolves.not.toThrow();
}
public async submitInteraction() {
return submitInteraction(this.interactionCookie);
}
private readonly consent = async () => {
// Note: If sign in action completed successfully, we will get `_session.sig` in the cookie.
assert(this.interactionCookie, new Error('Session not found'));

View file

@ -17,6 +17,8 @@ import {
import MockClient from '#src/client/index.js';
import { generateUsername, generatePassword } from '#src/utils.js';
import { enableAllPasswordSignInMethods } from './tests/api/interaction/utils/sign-in-experience.js';
export const createUserByAdmin = (
username?: string,
password?: string,
@ -112,6 +114,7 @@ export const bindSocialToNewCreatedUser = async (connectorId: string) => {
const username = generateUsername();
const password = generatePassword();
await enableAllPasswordSignInMethods();
await createUserByAdmin(username, password);
const state = 'mock_state';

View file

@ -34,13 +34,13 @@ describe('audit logs for interaction', () => {
// Process interaction with minimum effort
const { username, password } = generateNewUserProfile({ username: true, password: true });
const response = await putInteraction(
{
event: Event.Register,
profile: { username, password },
},
client.interactionCookie
);
await client.send(putInteraction, {
event: Event.Register,
profile: { username, password },
});
const response = await client.submitInteraction();
await client.processSession(response.redirectTo);
// Expect interaction end log

View file

@ -11,12 +11,15 @@ import { logtoUrl } from '#src/constants.js';
import { createUserByAdmin } from '#src/helpers.js';
import { generateUsername, generatePassword } from '#src/utils.js';
import { enableAllPasswordSignInMethods } from './interaction/utils/sign-in-experience.js';
describe('get access token', () => {
const username = generateUsername();
const password = generatePassword();
beforeAll(async () => {
await createUserByAdmin(username, password);
await enableAllPasswordSignInMethods();
});
it('sign-in and getAccessToken', async () => {

View file

@ -1,18 +1,18 @@
import { Event, ConnectorType } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { Event, ConnectorType, SignInIdentifier } from '@logto/schemas';
import {
putInteraction,
sendVerificationPasscode,
deleteUser,
patchInteraction,
patchInteractionIdentifiers,
patchInteractionProfile,
} from '#src/api/index.js';
import { expectRejects, readPasscode } from '#src/helpers.js';
import { generatePassword } from '#src/utils.js';
import { initClient, processSession, logoutClient } from './utils/client.js';
import { clearConnectorsByTypes, setEmailConnector, setSmsConnector } from './utils/connector.js';
import { enableAllPasswordSignInMethods } from './utils/sign-in-experience.js';
import { enableAllPasscodeSignInMethods } from './utils/sign-in-experience.js';
import { generateNewUser } from './utils/user.js';
describe('reset password', () => {
@ -20,11 +20,17 @@ describe('reset password', () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await setEmailConnector();
await setSmsConnector();
await enableAllPasswordSignInMethods();
await enableAllPasscodeSignInMethods({
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
password: true,
verify: true,
});
});
afterAll(async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
});
it('reset password with email', async () => {
const { user, userProfile } = await generateNewUser({
primaryEmail: true,
@ -32,17 +38,12 @@ describe('reset password', () => {
});
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.ForgotPassword,
email: userProfile.primaryEmail,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, { event: Event.ForgotPassword });
await client.successSend(sendVerificationPasscode, {
event: Event.ForgotPassword,
email: userProfile.primaryEmail,
});
const passcodeRecord = await readPasscode();
@ -53,57 +54,32 @@ describe('reset password', () => {
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.ForgotPassword,
identifier: {
email: userProfile.primaryEmail,
passcode: code,
},
},
client.interactionCookie
),
'user.new_password_required_in_profile'
);
await client.successSend(patchInteractionIdentifiers, {
email: userProfile.primaryEmail,
passcode: code,
});
await expectRejects(
patchInteraction(
{
event: Event.ForgotPassword,
profile: {
password: userProfile.password,
},
},
client.interactionCookie
),
'user.same_password'
);
await expectRejects(client.submitInteraction(), 'user.new_password_required_in_profile');
await client.successSend(patchInteractionProfile, { password: userProfile.password });
await expectRejects(client.submitInteraction(), 'user.same_password');
const newPasscodeRecord = generatePassword();
await expect(
patchInteraction(
{
event: Event.ForgotPassword,
profile: {
password: newPasscodeRecord,
},
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(patchInteractionProfile, { password: newPasscodeRecord });
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
email: userProfile.primaryEmail,
password: newPasscodeRecord,
},
await client.submitInteraction();
await client.successSend(putInteraction, {
event: Event.SignIn,
identifier: {
email: userProfile.primaryEmail,
password: newPasscodeRecord,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
@ -116,17 +92,12 @@ describe('reset password', () => {
});
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.ForgotPassword,
phone: userProfile.primaryPhone,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, { event: Event.ForgotPassword });
await client.successSend(sendVerificationPasscode, {
event: Event.ForgotPassword,
phone: userProfile.primaryPhone,
});
const passcodeRecord = await readPasscode();
@ -137,57 +108,32 @@ describe('reset password', () => {
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.ForgotPassword,
identifier: {
phone: userProfile.primaryPhone,
passcode: code,
},
},
client.interactionCookie
),
'user.new_password_required_in_profile'
);
await client.successSend(patchInteractionIdentifiers, {
phone: userProfile.primaryPhone,
passcode: code,
});
await expectRejects(
patchInteraction(
{
event: Event.ForgotPassword,
profile: {
password: userProfile.password,
},
},
client.interactionCookie
),
'user.same_password'
);
await expectRejects(client.submitInteraction(), 'user.new_password_required_in_profile');
await client.successSend(patchInteractionProfile, { password: userProfile.password });
await expectRejects(client.submitInteraction(), 'user.same_password');
const newPasscodeRecord = generatePassword();
await expect(
patchInteraction(
{
event: Event.ForgotPassword,
profile: {
password: newPasscodeRecord,
},
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(patchInteractionProfile, { password: newPasscodeRecord });
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
phone: userProfile.primaryPhone,
password: newPasscodeRecord,
},
await client.submitInteraction();
await client.successSend(putInteraction, {
event: Event.SignIn,
identifier: {
phone: userProfile.primaryPhone,
password: newPasscodeRecord,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);

View file

@ -4,8 +4,11 @@ import { assert } from '@silverhand/essentials';
import {
sendVerificationPasscode,
putInteraction,
patchInteraction,
deleteUser,
patchInteractionIdentifiers,
patchInteractionProfile,
deleteInteractionProfile,
putInteractionEvent,
} from '#src/api/index.js';
import { readPasscode, expectRejects } from '#src/helpers.js';
@ -27,18 +30,16 @@ describe('Register with username and password', () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
const { redirectTo } = await putInteraction(
{
event: Event.Register,
profile: {
username,
password,
},
await client.send(putInteraction, {
event: Event.Register,
profile: {
username,
password,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
@ -65,17 +66,15 @@ describe('Register with passwordless identifier', () => {
const { primaryEmail } = generateNewUserProfile({ primaryEmail: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.Register,
email: primaryEmail,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.Register,
});
await client.successSend(sendVerificationPasscode, {
event: Event.Register,
email: primaryEmail,
});
const passcodeRecord = await readPasscode();
@ -86,19 +85,16 @@ describe('Register with passwordless identifier', () => {
const { code } = passcodeRecord;
const { redirectTo } = await putInteraction(
{
event: Event.Register,
identifier: {
email: primaryEmail,
passcode: code,
},
profile: {
email: primaryEmail,
},
},
client.interactionCookie
);
await client.successSend(patchInteractionIdentifiers, {
email: primaryEmail,
passcode: code,
});
await client.successSend(patchInteractionProfile, {
email: primaryEmail,
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
@ -114,17 +110,15 @@ describe('Register with passwordless identifier', () => {
const { primaryPhone } = generateNewUserProfile({ primaryPhone: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.Register,
phone: primaryPhone,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.Register,
});
await client.successSend(sendVerificationPasscode, {
event: Event.Register,
phone: primaryPhone,
});
const passcodeRecord = await readPasscode();
@ -135,19 +129,16 @@ describe('Register with passwordless identifier', () => {
const { code } = passcodeRecord;
const { redirectTo } = await putInteraction(
{
event: Event.Register,
identifier: {
phone: primaryPhone,
passcode: code,
},
profile: {
phone: primaryPhone,
},
},
client.interactionCookie
);
await client.successSend(patchInteractionIdentifiers, {
phone: primaryPhone,
passcode: code,
});
await client.successSend(patchInteractionProfile, {
phone: primaryPhone,
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
@ -167,17 +158,15 @@ describe('Register with passwordless identifier', () => {
});
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.Register,
email: primaryEmail,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.Register,
});
await client.successSend(sendVerificationPasscode, {
event: Event.Register,
email: primaryEmail,
});
const passcodeRecord = await readPasscode();
@ -188,29 +177,21 @@ describe('Register with passwordless identifier', () => {
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.Register,
identifier: {
email: primaryEmail,
passcode: code,
},
profile: {
email: primaryEmail,
},
},
client.interactionCookie
),
'user.email_already_in_use'
);
await client.successSend(patchInteractionIdentifiers, {
email: primaryEmail,
passcode: code,
});
const { redirectTo } = await patchInteraction(
{
event: Event.SignIn,
},
client.interactionCookie
);
await client.successSend(patchInteractionProfile, {
email: primaryEmail,
});
await expectRejects(client.submitInteraction(), 'user.email_already_in_use');
await client.successSend(deleteInteractionProfile);
await client.successSend(putInteractionEvent, { event: Event.SignIn });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(user.id);
@ -231,15 +212,14 @@ describe('Register with passwordless identifier', () => {
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.Register,
phone: primaryPhone,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.Register,
});
await client.successSend(sendVerificationPasscode, {
event: Event.Register,
phone: primaryPhone,
});
const passcodeRecord = await readPasscode();
@ -250,29 +230,21 @@ describe('Register with passwordless identifier', () => {
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.Register,
identifier: {
phone: primaryPhone,
passcode: code,
},
profile: {
phone: primaryPhone,
},
},
client.interactionCookie
),
'user.phone_already_in_use'
);
await client.successSend(patchInteractionIdentifiers, {
phone: primaryPhone,
passcode: code,
});
const { redirectTo } = await patchInteraction(
{
event: Event.SignIn,
},
client.interactionCookie
);
await client.successSend(patchInteractionProfile, {
phone: primaryPhone,
});
await expectRejects(client.submitInteraction(), 'user.phone_already_in_use');
await client.successSend(deleteInteractionProfile);
await client.successSend(putInteractionEvent, { event: Event.SignIn });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(user.id);

View file

@ -1,10 +1,11 @@
import { ConnectorType, Event, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import {
sendVerificationPasscode,
putInteraction,
patchInteraction,
putInteractionEvent,
patchInteractionProfile,
patchInteractionIdentifiers,
deleteUser,
updateSignInExperience,
} from '#src/api/index.js';
@ -30,17 +31,15 @@ describe('Sign-In flow using passcode identifiers', () => {
it('sign-in with email and passcode', async () => {
const { userProfile, user } = await generateNewUser({ primaryEmail: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.SignIn,
email: userProfile.primaryEmail,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(sendVerificationPasscode, {
event: Event.SignIn,
email: userProfile.primaryEmail,
});
const passcodeRecord = await readPasscode();
@ -51,16 +50,12 @@ describe('Sign-In flow using passcode identifiers', () => {
const { code } = passcodeRecord;
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
email: userProfile.primaryEmail,
passcode: code,
},
},
client.interactionCookie
);
await client.successSend(patchInteractionIdentifiers, {
email: userProfile.primaryEmail,
passcode: code,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
@ -70,17 +65,15 @@ describe('Sign-In flow using passcode identifiers', () => {
it('sign-in with phone and passcode', async () => {
const { userProfile, user } = await generateNewUser({ primaryPhone: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.SignIn,
phone: userProfile.primaryPhone,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(sendVerificationPasscode, {
event: Event.SignIn,
phone: userProfile.primaryPhone,
});
const passcodeRecord = await readPasscode();
@ -91,16 +84,12 @@ describe('Sign-In flow using passcode identifiers', () => {
const { code } = passcodeRecord;
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
phone: userProfile.primaryPhone,
passcode: code,
},
},
client.interactionCookie
);
await client.successSend(patchInteractionIdentifiers, {
phone: userProfile.primaryPhone,
passcode: code,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
@ -116,45 +105,31 @@ describe('Sign-In flow using passcode identifiers', () => {
});
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.SignIn,
email: newEmail,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(sendVerificationPasscode, {
event: Event.SignIn,
email: newEmail,
});
const passcodeRecord = await readPasscode();
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.SignIn,
identifier: {
email: newEmail,
passcode: code,
},
},
client.interactionCookie
),
'user.user_not_exist'
);
await client.successSend(patchInteractionIdentifiers, {
email: newEmail,
passcode: code,
});
const { redirectTo } = await patchInteraction(
{
event: Event.Register,
profile: {
email: newEmail,
},
},
client.interactionCookie
);
await expectRejects(client.submitInteraction(), 'user.user_not_exist');
await client.successSend(putInteractionEvent, { event: Event.Register });
await client.successSend(patchInteractionProfile, { email: newEmail });
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
@ -170,45 +145,31 @@ describe('Sign-In flow using passcode identifiers', () => {
});
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
await expect(
sendVerificationPasscode(
{
event: Event.SignIn,
phone: newPhone,
},
client.interactionCookie
)
).resolves.not.toThrow();
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(sendVerificationPasscode, {
event: Event.SignIn,
phone: newPhone,
});
const passcodeRecord = await readPasscode();
const { code } = passcodeRecord;
await expectRejects(
putInteraction(
{
event: Event.SignIn,
identifier: {
phone: newPhone,
passcode: code,
},
},
client.interactionCookie
),
'user.user_not_exist'
);
await client.successSend(patchInteractionIdentifiers, {
phone: newPhone,
passcode: code,
});
const { redirectTo } = await patchInteraction(
{
event: Event.Register,
profile: {
phone: newPhone,
},
},
client.interactionCookie
);
await expectRejects(client.submitInteraction(), 'user.user_not_exist');
await client.successSend(putInteractionEvent, { event: Event.Register });
await client.successSend(patchInteractionProfile, { phone: newPhone });
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);

View file

@ -1,5 +1,4 @@
import { Event } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { putInteraction, deleteUser } from '#src/api/index.js';
@ -15,18 +14,16 @@ describe('Sign-In flow using password identifiers', () => {
it('sign-in with username and password', async () => {
const { userProfile, user } = await generateNewUser({ username: true, password: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
username: userProfile.username,
password: userProfile.password,
},
await client.successSend(putInteraction, {
event: Event.SignIn,
identifier: {
username: userProfile.username,
password: userProfile.password,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
@ -37,18 +34,16 @@ describe('Sign-In flow using password identifiers', () => {
it('sign-in with email and password', async () => {
const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
email: userProfile.primaryEmail,
password: userProfile.password,
},
await client.successSend(putInteraction, {
event: Event.SignIn,
identifier: {
email: userProfile.primaryEmail,
password: userProfile.password,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
@ -59,18 +54,16 @@ describe('Sign-In flow using password identifiers', () => {
it('sign-in with phone and password', async () => {
const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true });
const client = await initClient();
assert(client.interactionCookie, new Error('Session not found'));
const { redirectTo } = await putInteraction(
{
event: Event.SignIn,
identifier: {
phone: userProfile.primaryPhone,
password: userProfile.password,
},
await client.successSend(putInteraction, {
event: Event.SignIn,
identifier: {
phone: userProfile.primaryPhone,
password: userProfile.password,
},
client.interactionCookie
);
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);

View file

@ -0,0 +1,211 @@
import { ConnectorType, Event } from '@logto/schemas';
import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
import {
createSocialAuthorizationUri,
putInteraction,
deleteUser,
putInteractionEvent,
patchInteractionIdentifiers,
patchInteractionProfile,
} from '#src/api/index.js';
import { expectRejects } from '#src/helpers.js';
import { generateUserId } from '#src/utils.js';
import { initClient, logoutClient, processSession } from './utils/client.js';
import {
clearConnectorsByTypes,
clearConnectorById,
setSocialConnector,
} from './utils/connector.js';
import { enableAllPasswordSignInMethods } from './utils/sign-in-experience.js';
import { generateNewUser } from './utils/user.js';
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
const code = 'auth_code_foo';
describe('Social Identifier Interactions', () => {
const connectorIdMap = new Map<string, string>();
beforeAll(async () => {
await enableAllPasswordSignInMethods();
await clearConnectorsByTypes([ConnectorType.Social]);
const { id } = await setSocialConnector();
connectorIdMap.set(mockSocialConnectorId, id);
});
afterAll(async () => {
await clearConnectorById(connectorIdMap.get(mockSocialConnectorId) ?? '');
});
describe('register new and sign-in', () => {
const socialUserId = generateUserId();
it('register with social', async () => {
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: { state, redirectUri, code, userId: socialUserId },
});
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(putInteractionEvent, { event: Event.Register });
await client.successSend(patchInteractionProfile, { connectorId });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
/*
* Note: As currently we can not prepare a social identities through admin api.
* The sign-in test case MUST run concurrently after the register test case
*/
it('sign in with social', async () => {
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: { state, redirectUri, code, userId: socialUserId },
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(id);
});
});
describe('bind to existing account and sign-in', () => {
const socialUserId = generateUserId();
it('bind new social to a existing account', async () => {
const {
userProfile: { username, password },
} = await generateNewUser({ username: true, password: true });
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: { state, redirectUri, code, userId: socialUserId },
});
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(patchInteractionIdentifiers, { username, password });
await client.successSend(patchInteractionProfile, { connectorId });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
it('sign in with social', async () => {
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: { state, redirectUri, code, userId: socialUserId },
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(id);
});
});
describe('bind with existing email account', () => {
const socialUserId = generateUserId();
it('bind new social to a existing account', async () => {
const { userProfile } = await generateNewUser({ primaryEmail: true });
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: {
state,
redirectUri,
code,
userId: socialUserId,
email: userProfile.primaryEmail,
},
});
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(patchInteractionIdentifiers, { connectorId, identityType: 'email' });
await client.successSend(patchInteractionProfile, { connectorId });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
it('sign in with social', async () => {
const client = await initClient();
const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? '';
await client.successSend(putInteraction, {
event: Event.SignIn,
});
await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
connectorData: { state, redirectUri, code, userId: socialUserId },
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(id);
});
});
});

View file

@ -1,8 +1,11 @@
import { assert } from '@silverhand/essentials';
import MockClient from '#src/client/index.js';
export const initClient = async () => {
const client = new MockClient();
await client.initSession();
assert(client.interactionCookie, new Error('Session not found'));
return client;
};

View file

@ -5,6 +5,8 @@ import {
mockEmailConnectorId,
mockSmsConnectorConfig,
mockSmsConnectorId,
mockSocialConnectorId,
mockSocialConnectorConfig,
} from '#src/__mocks__/connectors-mock.js';
import { listConnectors, deleteConnectorById, postConnector } from '#src/api/index.js';
@ -14,6 +16,8 @@ export const clearConnectorsByTypes = async (types: ConnectorType[]) => {
await Promise.all(targetConnectors.map(async (connector) => deleteConnectorById(connector.id)));
};
export const clearConnectorById = async (connectorId: string) => deleteConnectorById(connectorId);
export const setEmailConnector = async () =>
postConnector({
connectorId: mockEmailConnectorId,
@ -25,3 +29,9 @@ export const setSmsConnector = async () =>
connectorId: mockSmsConnectorId,
config: mockSmsConnectorConfig,
});
export const setSocialConnector = async () =>
postConnector({
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
});

View file

@ -1,4 +1,5 @@
export const generateName = () => crypto.randomUUID();
export const generateUserId = () => crypto.randomUUID();
export const generateUsername = () => `usr_${crypto.randomUUID().replaceAll('-', '_')}`;
export const generatePassword = () => `pwd_${crypto.randomUUID()}`;

View file

@ -88,6 +88,9 @@ const errors = {
'Die Verifizierung war nicht erfolgreich. Starte die Verifizierung neu und versuche es erneut.',
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
// UNTRANSLATED

View file

@ -88,6 +88,9 @@ const errors = {
'The verification was not successful. Restart the verification flow and try again.',
connector_validation_session_not_found:
'The connector session for token validation is not found.',
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',
},
connector: {
general: 'An unexpected error occurred in connector.{{errorDescription}}',

View file

@ -93,6 +93,9 @@ const errors = {
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: "Une erreur inattendue s'est produite dans le connecteur. {{errorDescription}}",

View file

@ -87,6 +87,9 @@ const errors = {
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: '연동 중에 알 수 없는 오류가 발생했어요. {{errorDescription}}',

View file

@ -89,6 +89,9 @@ const errors = {
'A verificação não foi bem-sucedida. Reinicie o fluxo de verificação e tente novamente.',
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: 'Ocorreu um erro inesperado no conector.{{errorDescription}}',

View file

@ -89,6 +89,9 @@ const errors = {
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: 'Ocorreu um erro inesperado no conector.{{errorDescription}}',

View file

@ -89,6 +89,9 @@ const errors = {
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
connector_validation_session_not_found:
'The connector session for token validation is not found.', // UNTRANSLATED
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: 'Bağlayıcıda beklenmeyen bir hata oldu.{{errorDescription}}',

View file

@ -81,6 +81,9 @@ const errors = {
forgot_password_not_enabled: '忘记密码功能没有开启。',
verification_failed: '验证失败,请重新验证。',
connector_validation_session_not_found: '找不到连接器用于验证 token 的信息。',
identifier_not_found: 'User identifier not found. Please go back and sign in again.', // UNTRANSLATED
interaction_not_found:
'Interaction session not found. Please go back and start the session again.', // UNTRANSLATED
},
connector: {
general: '连接器发生未知错误{{errorDescription}}',

View file

@ -1,6 +1,8 @@
import { emailRegEx, phoneRegEx, usernameRegEx, passwordRegEx } from '@logto/core-kit';
import { z } from 'zod';
import { arbitraryObjectGuard } from '../foundations/index.js';
/**
* Detailed Identifier Methods guard
*/
@ -37,7 +39,7 @@ export type PhonePasscodePayload = z.infer<typeof phonePasscodePayloadGuard>;
export const socialConnectorPayloadGuard = z.object({
connectorId: z.string(),
connectorData: z.unknown(),
connectorData: arbitraryObjectGuard,
});
export type SocialConnectorPayload = z.infer<typeof socialConnectorPayloadGuard>;