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:
parent
8135246e41
commit
15c7c1605a
50 changed files with 1549 additions and 1720 deletions
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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')),
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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, {});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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()}`;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue