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

refactor(core): migrate social library to factory mode

This commit is contained in:
Gao Sun 2023-01-10 18:05:28 +08:00
parent 1b998b7e62
commit 9bec890e6f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
12 changed files with 155 additions and 122 deletions

View file

@ -7,8 +7,8 @@ import type { InteractionResults } from 'oidc-provider';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { getLogtoConnectorById } from '#src/libraries/connector.js';
import { findUserByEmail, findUserByPhone } from '#src/queries/user.js';
import type { ConnectorLibrary } from '#src/libraries/connector.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
export type SocialUserInfoSession = {
@ -16,42 +16,7 @@ export type SocialUserInfoSession = {
userInfo: SocialUserInfo;
};
const getConnector = async (connectorId: string) => {
try {
return await getLogtoConnectorById(connectorId);
} catch (error: unknown) {
// Throw a new error with status 422 when connector not found.
if (error instanceof RequestError && error.code === 'entity.not_found') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
});
}
throw error;
}
};
export const getUserInfoByAuthCode = async (
connectorId: string,
data: unknown,
getConnectorSession?: GetSession
): Promise<SocialUserInfo> => {
const connector = await getConnector(connectorId);
assertThat(
connector.type === ConnectorType.Social,
new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
})
);
return connector.getUserInfo(data, getConnectorSession);
};
export const getUserInfoFromInteractionResult = async (
const getUserInfoFromInteractionResult = async (
connectorId: string,
interactionResult: InteractionResults
): Promise<SocialUserInfo> => {
@ -74,31 +39,75 @@ export const getUserInfoFromInteractionResult = async (
return result.socialUserInfo.userInfo;
};
/**
* Find user by phone/email from social user info.
* if both phone and email exist, take phone for priority.
*
* @param info SocialUserInfo
* @returns null | [string, User] the first string indicating phone or email
*/
export const findSocialRelatedUser = async (
info: SocialUserInfo
): Promise<Nullable<[{ type: 'email' | 'phone'; value: string }, User]>> => {
if (info.phone) {
const user = await findUserByPhone(info.phone);
export type SocialLibrary = ReturnType<typeof createSocialLibrary>;
if (user) {
return [{ type: 'phone', value: info.phone }, user];
export const createSocialLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => {
const { findUserByEmail, findUserByPhone } = queries.users;
const { getLogtoConnectorById } = connectorLibrary;
const getConnector = async (connectorId: string) => {
try {
return await getLogtoConnectorById(connectorId);
} catch (error: unknown) {
// Throw a new error with status 422 when connector not found.
if (error instanceof RequestError && error.code === 'entity.not_found') {
throw new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
});
}
throw error;
}
}
};
if (info.email) {
const user = await findUserByEmail(info.email);
const getUserInfoByAuthCode = async (
connectorId: string,
data: unknown,
getConnectorSession?: GetSession
): Promise<SocialUserInfo> => {
const connector = await getConnector(connectorId);
if (user) {
return [{ type: 'email', value: info.email }, user];
assertThat(
connector.type === ConnectorType.Social,
new RequestError({
code: 'session.invalid_connector_id',
status: 422,
connectorId,
})
);
return connector.getUserInfo(data, getConnectorSession);
};
/**
* Find user by phone/email from social user info.
* if both phone and email exist, take phone for priority.
*
* @param info SocialUserInfo
* @returns null | [string, User] the first string indicating phone or email
*/
const findSocialRelatedUser = async (
info: SocialUserInfo
): Promise<Nullable<[{ type: 'email' | 'phone'; value: string }, User]>> => {
if (info.phone) {
const user = await findUserByPhone(info.phone);
if (user) {
return [{ type: 'phone', value: info.phone }, user];
}
}
}
return null;
if (info.email) {
const user = await findUserByEmail(info.email);
if (user) {
return [{ type: 'email', value: info.email }, user];
}
}
return null;
};
return { getUserInfoByAuthCode, getUserInfoFromInteractionResult, findSocialRelatedUser };
};

View file

@ -6,7 +6,9 @@ import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { createMockTenantWithInteraction, MockTenant } from '#src/test-utils/tenant.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
import { verificationPath, interactionPrefix } from './const.js';
@ -33,21 +35,6 @@ const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
};
});
await mockEsmWithActual('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
return connector;
}),
}));
await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
}));
@ -119,7 +106,27 @@ describe('interaction routes', () => {
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
tenantContext: createMockTenantWithInteraction(jest.fn().mockResolvedValue(baseProviderMock)),
tenantContext: new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
undefined,
{
connectors: {
getLogtoConnectorById: async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
// @ts-expect-error
return connector as LogtoConnector;
},
},
}
),
});
afterEach(() => {

View file

@ -289,7 +289,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
const log = createLog(`Interaction.${event}.Submit`);
log.append({ interaction: interactionStorage });
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
const accountVerifiedInteraction = await verifyIdentifier(ctx, tenant, interactionStorage);
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
@ -316,7 +316,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
log.append(payload);
const redirectTo = await createSocialAuthorizationUrl(ctx, provider, payload);
const redirectTo = await createSocialAuthorizationUrl(ctx, tenant, payload);
ctx.body = { redirectTo };

View file

@ -4,14 +4,14 @@ import { createMockUtils } from '@logto/shared/esm';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { getUserInfoByAuthCode } = mockEsm('#src/libraries/social.js', () => ({
getUserInfoByAuthCode: jest.fn().mockResolvedValue({ id: 'foo' }),
}));
const getUserInfoByAuthCode = jest.fn().mockResolvedValue({ id: 'foo' });
const tenant = new MockTenant(undefined, undefined, { socials: { getUserInfoByAuthCode } });
mockEsm('#src/libraries/connector.js', () => ({
getLogtoConnectorById: jest.fn().mockResolvedValue({
@ -27,7 +27,6 @@ const { verifySocialIdentity } = await import('./social-verification.js');
describe('social-verification', () => {
it('verifySocialIdentity', async () => {
const provider = createMockProvider();
// @ts-expect-error test mock context
const ctx: WithLogContext = {
...createMockContext(),
@ -35,7 +34,7 @@ describe('social-verification', () => {
};
const connectorId = 'connector';
const connectorData = { authCode: 'code' };
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, ctx, provider);
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, ctx, tenant);
expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData, expect.anything());
expect(userInfo).toEqual({ id: 'foo' });

View file

@ -1,24 +1,26 @@
import type { ConnectorSession, SocialUserInfo } from '@logto/connector-kit';
import type { SocialConnectorPayload } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import type Provider from 'oidc-provider';
import { getLogtoConnectorById } from '#src/libraries/connector.js';
import { getUserInfoByAuthCode } from '#src/libraries/social.js';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import {
getConnectorSessionResult,
assignConnectorSessionResult,
} from '#src/routes/interaction/utils/interaction.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import type { SocialAuthorizationUrlPayload } from '../types/index.js';
export const createSocialAuthorizationUrl = async (
ctx: WithLogContext,
provider: Provider,
{ provider, libraries }: TenantContext,
payload: SocialAuthorizationUrlPayload
) => {
const {
connectors: { getLogtoConnectorById },
} = libraries;
const { connectorId, state, redirectUri } = payload;
assertThat(state && redirectUri, 'session.insufficient_info');
@ -40,8 +42,12 @@ export const createSocialAuthorizationUrl = async (
export const verifySocialIdentity = async (
{ connectorId, connectorData }: SocialConnectorPayload,
ctx: WithLogContext,
provider: Provider
{ provider, libraries }: TenantContext
): Promise<SocialUserInfo> => {
const {
socials: { getUserInfoByAuthCode },
} = libraries;
const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });

View file

@ -163,7 +163,7 @@ describe('identifier verification', () => {
interactionStorage
);
expect(verifySocialIdentity).toBeCalledWith(identifier, baseCtx, tenant.provider);
expect(verifySocialIdentity).toBeCalledWith(identifier, baseCtx, tenant);
expect(findUserByIdentifier).not.toBeCalled();
expect(result).toEqual({

View file

@ -68,9 +68,9 @@ const verifyVerificationCodeIdentifier = async (
const verifySocialIdentifier = async (
identifier: SocialConnectorPayload,
ctx: WithLogContext,
{ provider }: TenantContext
tenant: TenantContext
): Promise<SocialIdentifier> => {
const userInfo = await verifySocialIdentity(identifier, ctx, provider);
const userInfo = await verifySocialIdentity(identifier, ctx, tenant);
return { key: 'social', connectorId: identifier.connectorId, userInfo };
};

View file

@ -2,7 +2,7 @@ import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, 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 { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { SignInInteractionResult } from '../types/index.js';
@ -22,7 +22,7 @@ describe('verifyIdentifier', () => {
...createContextWithRouteParameters(),
...createMockLogContext(),
};
const provider = createMockProvider();
const tenant = new MockTenant();
afterEach(() => {
jest.clearAllMocks();
@ -33,7 +33,7 @@ describe('verifyIdentifier', () => {
event: InteractionEvent.Register,
};
const result = await verifyIdentifier(ctx, provider, interactionRecord);
const result = await verifyIdentifier(ctx, tenant, interactionRecord);
expect(result).toBe(interactionRecord);
expect(verifyUserAccount).not.toBeCalled();
@ -53,10 +53,10 @@ describe('verifyIdentifier', () => {
verifyUserAccount.mockResolvedValue(verifiedRecord);
const result = await verifyIdentifier(ctx, provider, interactionRecord);
const result = await verifyIdentifier(ctx, tenant, interactionRecord);
expect(result).toBe(verifiedRecord);
expect(verifyUserAccount).toBeCalledWith(interactionRecord);
expect(storeInteractionResult).toBeCalledWith(verifiedRecord, ctx, provider);
expect(verifyUserAccount).toBeCalledWith(interactionRecord, tenant.libraries.socials);
expect(storeInteractionResult).toBeCalledWith(verifiedRecord, ctx, tenant.provider);
});
});

View file

@ -1,6 +1,7 @@
import { InteractionEvent } from '@logto/schemas';
import type { Context } from 'koa';
import type Provider from 'oidc-provider';
import type TenantContext from '#src/tenants/TenantContext.js';
import type {
RegisterInteractionResult,
@ -18,7 +19,7 @@ type InteractionResult =
export default async function verifyIdentifier(
ctx: Context,
provider: Provider,
{ provider, libraries }: TenantContext,
interactionRecord: InteractionResult
): Promise<RegisterInteractionResult | AccountVerifiedInteractionResult> {
if (interactionRecord.event === InteractionEvent.Register) {
@ -26,7 +27,10 @@ export default async function verifyIdentifier(
}
// Verify the user account and assign the verified result to the interaction record
const accountVerifiedInteractionResult = await verifyUserAccount(interactionRecord);
const accountVerifiedInteractionResult = await verifyUserAccount(
interactionRecord,
libraries.socials
);
await storeInteractionResult(accountVerifiedInteractionResult, ctx, provider);
return accountVerifiedInteractionResult;

View file

@ -2,6 +2,7 @@ import { InteractionEvent } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import RequestError from '#src/errors/RequestError/index.js';
import type { SocialLibrary } from '#src/libraries/social.js';
import type { SignInInteractionResult } from '../types/index.js';
@ -10,9 +11,10 @@ const { mockEsm, mockEsmDefault } = createMockUtils(jest);
const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn());
mockEsm('#src/libraries/social.js', () => ({
// @ts-expect-error
const socialLibrary: SocialLibrary = {
findSocialRelatedUser: jest.fn().mockResolvedValue(null),
}));
};
const verifyUserAccount = await pickDefault(import('./user-identity-verification.js'));
@ -28,7 +30,7 @@ describe('verifyUserAccount', () => {
event: InteractionEvent.SignIn,
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError({ code: 'session.identifier_not_found', status: 404 })
);
});
@ -39,7 +41,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'accountId', value: 'foo' }],
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(result).toEqual({ ...interaction, accountId: 'foo' });
});
@ -52,7 +54,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
@ -66,7 +68,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'phoneVerified', value: '123456' }],
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ phone: '123456' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
@ -80,7 +82,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({
connectorId: 'connectorId',
userInfo: { id: 'foo' },
@ -97,7 +99,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError(
{
code: 'user.identity_not_exist',
@ -124,7 +126,7 @@ describe('verifyUserAccount', () => {
],
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });
@ -141,7 +143,7 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' })
);
@ -161,7 +163,7 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError({ code: 'user.suspended', status: 401 })
);
@ -182,7 +184,7 @@ describe('verifyUserAccount', () => {
],
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' });
@ -198,7 +200,7 @@ describe('verifyUserAccount', () => {
identifiers: [{ key: 'emailVerified', value: 'email' }],
};
await expect(verifyUserAccount(interaction)).rejects.toMatchError(
await expect(verifyUserAccount(interaction, socialLibrary)).rejects.toMatchError(
new RequestError('session.verification_failed')
);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
@ -220,7 +222,7 @@ describe('verifyUserAccount', () => {
},
};
const result = await verifyUserAccount(interaction);
const result = await verifyUserAccount(interaction, socialLibrary);
expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' });
expect(result).toEqual({ ...interaction, accountId: 'foo' });

View file

@ -1,7 +1,7 @@
import { deduplicate } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import { findSocialRelatedUser } from '#src/libraries/social.js';
import type { SocialLibrary } from '#src/libraries/social.js';
import assertThat from '#src/utils/assert-that.js';
import { maskUserInfo } from '#src/utils/format.js';
@ -36,13 +36,16 @@ const identifyUserByVerifiedEmailOrPhone = async (
return id;
};
const identifyUserBySocialIdentifier = async (identifier: SocialIdentifier) => {
const identifyUserBySocialIdentifier = async (
identifier: SocialIdentifier,
socialLibrary: SocialLibrary
) => {
const { connectorId, userInfo } = identifier;
const user = await findUserByIdentifier({ connectorId, userInfo });
if (!user) {
const relatedInfo = await findSocialRelatedUser(userInfo);
const relatedInfo = await socialLibrary.findSocialRelatedUser(userInfo);
throw new RequestError(
{
@ -60,9 +63,9 @@ const identifyUserBySocialIdentifier = async (identifier: SocialIdentifier) => {
return id;
};
const identifyUser = async (identifier: Identifier) => {
const identifyUser = async (identifier: Identifier, socialLibrary: SocialLibrary) => {
if (identifier.key === 'social') {
return identifyUserBySocialIdentifier(identifier);
return identifyUserBySocialIdentifier(identifier, socialLibrary);
}
if (identifier.key === 'accountId') {
@ -73,7 +76,8 @@ const identifyUser = async (identifier: Identifier) => {
};
export default async function verifyUserAccount(
interaction: SignInInteractionResult | ForgotPasswordInteractionResult
interaction: SignInInteractionResult | ForgotPasswordInteractionResult,
socialLibrary: SocialLibrary
): Promise<AccountVerifiedInteractionResult> {
const { identifiers = [], accountId, profile } = interaction;
@ -91,7 +95,7 @@ export default async function verifyUserAccount(
// Verify authIdentifiers
const accountIds = await Promise.all(
authIdentifiers.map(async (identifier) => identifyUser(identifier))
authIdentifiers.map(async (identifier) => identifyUser(identifier, socialLibrary))
);
const deduplicateAccountIds = deduplicate(accountIds);

View file

@ -3,6 +3,7 @@ import { createHookLibrary } from '#src/libraries/hook.js';
import { createPhraseLibrary } from '#src/libraries/phrase.js';
import { createResourceLibrary } from '#src/libraries/resource.js';
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
import { createSocialLibrary } from '#src/libraries/social.js';
import { createUserLibrary } from '#src/libraries/user.js';
import type { ModelRouters } from '#src/model-routers/index.js';
@ -15,6 +16,7 @@ export default class Libraries {
phrases = createPhraseLibrary(this.queries);
resources = createResourceLibrary(this.queries);
hooks = createHookLibrary(this.queries, this.modelRouters);
socials = createSocialLibrary(this.queries, this.connectors);
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
}