diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a59034d29..92f25e5f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,8 @@ concurrency: jobs: dockerize-core: environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }} - runs-on: ubuntu-latest + # Use normal machine for OSS release since we'll build on Depot + runs-on: ${{ startsWith(github.ref, 'refs/tags/') && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }} permissions: contents: read id-token: write @@ -94,7 +95,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} dockerize-cloud: - runs-on: ubuntu-latest + # Use normal machine for OSS release since we'll build on Depot + runs-on: ${{ startsWith(github.ref, 'refs/tags/') && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }} steps: - uses: actions/checkout@v3 diff --git a/packages/core/package.json b/packages/core/package.json index 53268eeed..ba86961db 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "lru-cache": "^7.14.1", "nanoid": "^4.0.0", "oidc-provider": "^8.0.0", + "p-memoize": "^7.1.1", "p-retry": "^5.1.2", "pg-protocol": "^1.6.0", "roarr": "^7.11.0", diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index d8400f475..83aeb06b6 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -1,3 +1,5 @@ +import { createServer } from 'http'; + import { pickDefault } from '@logto/shared/esm'; import Koa from 'koa'; @@ -7,7 +9,9 @@ const initI18n = await pickDefault(import('../i18n/init.js')); const initApp = await pickDefault(import('./init.js')); describe('App Init', () => { - const listenMock = jest.spyOn(Koa.prototype, 'listen').mockImplementation(jest.fn()); + const listenMock = jest + .spyOn(Koa.prototype, 'listen') + .mockImplementation(jest.fn(() => createServer())); it('app init properly with 404 not found route', async () => { const app = new Koa(); diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 52c9878a7..7a9082bd8 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -17,6 +17,8 @@ const logListening = (type: 'core' | 'admin' = 'core') => { } }; +const serverTimeout = 120_000; + const getTenant = async (tenantId: string) => { try { return await tenantPool.get(tenantId); @@ -76,6 +78,7 @@ export default async function initApp(app: Koa): Promise { coreServer.listen(urlSet.port, () => { logListening(); }); + coreServer.setTimeout(serverTimeout); // Create another server if admin localhost enabled if (!adminUrlSet.isLocalhostDisabled) { @@ -83,20 +86,23 @@ export default async function initApp(app: Koa): Promise { adminServer.listen(adminUrlSet.port, () => { logListening('admin'); }); + adminServer.setTimeout(serverTimeout); } return; } // Chrome doesn't allow insecure HTTP/2 servers, stick with HTTP for localhost. - app.listen(urlSet.port, () => { + const coreServer = app.listen(urlSet.port, () => { logListening(); }); + coreServer.setTimeout(serverTimeout); // Create another server if admin localhost enabled if (!adminUrlSet.isLocalhostDisabled) { - app.listen(adminUrlSet.port, () => { + const adminServer = app.listen(adminUrlSet.port, () => { logListening('admin'); }); + adminServer.setTimeout(serverTimeout); } } diff --git a/packages/core/src/caches/well-known.ts b/packages/core/src/caches/well-known.ts new file mode 100644 index 000000000..b5c74b2d7 --- /dev/null +++ b/packages/core/src/caches/well-known.ts @@ -0,0 +1,58 @@ +import { TtlCache } from '@logto/shared'; +import type { AnyAsyncFunction } from 'p-memoize'; +import pMemoize from 'p-memoize'; + +const cacheKeys = Object.freeze(['sie', 'sie-full', 'phrases', 'phrases-lng-tags'] as const); + +/** Well-known data type key for cache. */ +export type WellKnownCacheKey = (typeof cacheKeys)[number]; + +const buildKey = (tenantId: string, key: WellKnownCacheKey) => `${tenantId}:${key}` as const; + +class WellKnownCache { + #cache = new TtlCache(180_000 /* 3 minutes */); + + /** + * Use for centralized well-known data caching. + * + * WARN: + * - You should store only well-known (public) data since it's a central cache. + * - The cache does not guard types. + */ + use( + tenantId: string, + key: WellKnownCacheKey, + run: FunctionToMemoize + ) { + return pMemoize(run, { + cacheKey: () => buildKey(tenantId, key), + // Trust cache value type + // eslint-disable-next-line no-restricted-syntax + cache: this.#cache as TtlCache>>, + }); + } + + invalidate(tenantId: string, keys: readonly WellKnownCacheKey[]) { + for (const key of keys) { + this.#cache.delete(buildKey(tenantId, key)); + } + } + + invalidateAll(tenantId: string) { + this.invalidate(tenantId, cacheKeys); + } + + set(tenantId: string, key: WellKnownCacheKey, value: unknown) { + this.#cache.set(buildKey(tenantId, key), value); + } +} + +/** + * The central TTL cache for well-known data. The default TTL is 3 minutes. + * + * This cache is intended for public APIs that are tolerant for data freshness. + * For Management APIs, you should use uncached functions instead. + * + * WARN: You should store only well-known (public) data since it's a central cache. + */ +export const wellKnownCache = new WellKnownCache(); diff --git a/packages/core/src/libraries/phrase.test.ts b/packages/core/src/libraries/phrase.test.ts index f9faea10f..14545849c 100644 --- a/packages/core/src/libraries/phrase.test.ts +++ b/packages/core/src/libraries/phrase.test.ts @@ -11,6 +11,7 @@ import { zhCnTag, zhHkTag, } from '#src/__mocks__/custom-phrase.js'; +import { wellKnownCache } from '#src/caches/well-known.js'; import RequestError from '#src/errors/RequestError/index.js'; import { MockQueries } from '#src/test-utils/tenant.js'; @@ -41,12 +42,15 @@ const findCustomPhraseByLanguageTag = jest.fn(async (languageTag: string) => { return mockCustomPhrase; }); +const tenantId = 'mock_id'; const { createPhraseLibrary } = await import('#src/libraries/phrase.js'); const { getPhrases } = createPhraseLibrary( - new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } }) + new MockQueries({ customPhrases: { findCustomPhraseByLanguageTag } }), + tenantId ); afterEach(() => { + wellKnownCache.invalidateAll(tenantId); jest.clearAllMocks(); }); diff --git a/packages/core/src/libraries/phrase.ts b/packages/core/src/libraries/phrase.ts index bb6f163c5..40db75391 100644 --- a/packages/core/src/libraries/phrase.ts +++ b/packages/core/src/libraries/phrase.ts @@ -4,12 +4,16 @@ import type { CustomPhrase } from '@logto/schemas'; import cleanDeep from 'clean-deep'; import deepmerge from 'deepmerge'; +import { wellKnownCache } from '#src/caches/well-known.js'; import type Queries from '#src/tenants/Queries.js'; -export const createPhraseLibrary = (queries: Queries) => { - const { findCustomPhraseByLanguageTag } = queries.customPhrases; +export const createPhraseLibrary = (queries: Queries, tenantId: string) => { + const { findCustomPhraseByLanguageTag, findAllCustomLanguageTags } = queries.customPhrases; - const getPhrases = async (supportedLanguage: string, customLanguages: string[]) => { + const _getPhrases = async ( + supportedLanguage: string, + customLanguages: string[] + ): Promise => { if (!isBuiltInLanguageTag(supportedLanguage)) { return deepmerge( resource.en, @@ -27,5 +31,33 @@ export const createPhraseLibrary = (queries: Queries) => { ); }; - return { getPhrases }; + const getPhrases = wellKnownCache.use(tenantId, 'phrases', _getPhrases); + + const getAllCustomLanguageTags = wellKnownCache.use( + tenantId, + 'phrases-lng-tags', + findAllCustomLanguageTags + ); + + return { + /** + * NOTE: This function is cached by the first parameter. + * **Cache Invalidation** + * + * ```ts + * wellKnownCache.invalidate(tenantId, ['phrases']); + * ``` + */ + getPhrases, + /** + * NOTE: This function is cached. + * + * **Cache Invalidation** + * + * ```ts + * wellKnownCache.invalidate(tenantId, ['phrases-lng-tags']); + * ``` + */ + getAllCustomLanguageTags, + }; }; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index b48305b2d..660e82e26 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -42,7 +42,7 @@ const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors'); const { createSignInExperienceLibrary } = await import('./index.js'); const { validateLanguageInfo, removeUnavailableSocialConnectorTargets } = - createSignInExperienceLibrary(queries, connectorLibrary); + createSignInExperienceLibrary(queries, connectorLibrary, 'mock_id'); beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 5d055b63f..23b574461 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -1,8 +1,11 @@ +import { connectorMetadataGuard } from '@logto/connector-kit'; import { builtInLanguages } from '@logto/phrases-ui'; -import type { LanguageInfo, SignInExperience } from '@logto/schemas'; -import { ConnectorType } from '@logto/schemas'; +import type { ConnectorMetadata, LanguageInfo, SignInExperience } from '@logto/schemas'; +import { SignInExperiences, ConnectorType } from '@logto/schemas'; import { deduplicate } from '@silverhand/essentials'; +import { z } from 'zod'; +import { wellKnownCache } from '#src/caches/well-known.js'; import RequestError from '#src/errors/RequestError/index.js'; import type { ConnectorLibrary } from '#src/libraries/connector.js'; import type Queries from '#src/tenants/Queries.js'; @@ -15,12 +18,12 @@ export type SignInExperienceLibrary = ReturnType { const { customPhrases: { findAllCustomLanguageTags }, signInExperiences: { findDefaultSignInExperience, updateDefaultSignInExperience }, - users: { hasActiveUsers }, } = queries; const validateLanguageInfo = async (languageInfo: LanguageInfo) => { @@ -36,7 +39,7 @@ export const createSignInExperienceLibrary = ( }; const removeUnavailableSocialConnectorTargets = async () => { - const connectors = await connectorLibrary.getLogtoConnectors(); + const connectors = await getLogtoConnectors(); const availableSocialConnectorTargets = deduplicate( connectors .filter(({ type }) => type === ConnectorType.Social) @@ -52,11 +55,85 @@ export const createSignInExperienceLibrary = ( }); }; - const getSignInExperience = async (): Promise => findDefaultSignInExperience(); + const getSignInExperience = wellKnownCache.use(tenantId, 'sie', findDefaultSignInExperience); + + const _getFullSignInExperience = async (): Promise => { + const [signInExperience, logtoConnectors] = await Promise.all([ + getSignInExperience(), + getLogtoConnectors(), + ]); + + const forgotPassword = { + phone: logtoConnectors.some(({ type }) => type === ConnectorType.Sms), + email: logtoConnectors.some(({ type }) => type === ConnectorType.Email), + }; + + const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce< + Array + >((previous, connectorTarget) => { + const connectors = logtoConnectors.filter( + ({ metadata: { target } }) => target === connectorTarget + ); + + return [ + ...previous, + ...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })), + ]; + }, []); + + return { + ...signInExperience, + socialConnectors, + forgotPassword, + }; + }; + + const getFullSignInExperience = wellKnownCache.use( + tenantId, + 'sie-full', + _getFullSignInExperience + ); return { validateLanguageInfo, removeUnavailableSocialConnectorTargets, + /** + * NOTE: This function is cached. + * + * **Cache Invalidation** + * + * ```ts + * wellKnownCache.invalidate(tenantId, ['sie']); + * ``` + */ getSignInExperience, + /** + * NOTE: This function is cached. + * + * **Cache Invalidation** + * + * ```ts + * wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']); + * ``` + */ + getFullSignInExperience, }; }; + +export type ForgotPassword = { + phone: boolean; + email: boolean; +}; + +export type ConnectorMetadataWithId = ConnectorMetadata & { id: string }; + +export type FullSignInExperience = SignInExperience & { + socialConnectors: ConnectorMetadataWithId[]; + forgotPassword: ForgotPassword; +}; + +export const guardFullSignInExperience: z.ZodType = + SignInExperiences.guard.extend({ + socialConnectors: connectorMetadataGuard.extend({ id: z.string() }).array(), + forgotPassword: z.object({ phone: z.boolean(), email: z.boolean() }), + }); diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index 212f63204..58abc41d5 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -7,6 +7,6 @@ describe('oidc provider init', () => { it('init should not throw', async () => { const { queries, libraries } = new MockTenant(); - expect(() => initOidc(mockEnvSet, queries, libraries)).not.toThrow(); + expect(() => initOidc('mock_id', mockEnvSet, queries, libraries)).not.toThrow(); }); }); diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 9de5e3612..c8d803c79 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -9,6 +9,7 @@ import i18next from 'i18next'; import Provider, { errors, ResourceServer } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; +import { wellKnownCache } from '#src/caches/well-known.js'; import type { EnvSet } from '#src/env-set/index.js'; import { addOidcEventListeners } from '#src/event-listeners/index.js'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; @@ -25,7 +26,12 @@ import { OIDCExtraParametersKey, InteractionMode } from './type.js'; // Temporarily removed 'EdDSA' since it's not supported by browser yet const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const); -export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Libraries): Provider { +export default function initOidc( + tenantId: string, + envSet: EnvSet, + queries: Queries, + libraries: Libraries +): Provider { const { issuer, cookieKeys, @@ -141,23 +147,24 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li }, interactions: { url: (ctx, interaction) => { + const isDemoApp = interaction.params.client_id === demoAppApplicationId; + const appendParameters = (path: string) => { // `notification` is for showing a text banner on the homepage - return interaction.params.client_id === demoAppApplicationId - ? path + `?notification=demo_app.notification` - : path; + return isDemoApp ? path + `?notification=demo_app.notification` : path; }; switch (interaction.prompt.name) { case 'login': { - if ( - // Register user experience first - ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp - ) { - return appendParameters(routes.signUp); + // Always fetch the latest sign-in experience config for demo app (live preview) + if (isDemoApp) { + wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']); } - return appendParameters(routes.signIn.credentials); + const isSignUp = + ctx.oidc.params?.[OIDCExtraParametersKey.InteractionMode] === InteractionMode.signUp; + + return appendParameters(isSignUp ? routes.signUp : routes.signIn.credentials); } case 'consent': { diff --git a/packages/core/src/queries/custom-phrase.ts b/packages/core/src/queries/custom-phrase.ts index bb65c2fd6..369fbfddc 100644 --- a/packages/core/src/queries/custom-phrase.ts +++ b/packages/core/src/queries/custom-phrase.ts @@ -58,6 +58,10 @@ export const createCustomPhraseQueries = (pool: CommonQueryMethods) => { }; return { + /** + * NOTE: Use `getAllCustomLanguageTags()` from phrase library + * if possible since that function leverages cache. + */ findAllCustomLanguageTags, findAllCustomPhrases, findCustomPhraseByLanguageTag, diff --git a/packages/core/src/queries/sign-in-experience.ts b/packages/core/src/queries/sign-in-experience.ts index fe1a3f09d..6872660b1 100644 --- a/packages/core/src/queries/sign-in-experience.ts +++ b/packages/core/src/queries/sign-in-experience.ts @@ -16,5 +16,8 @@ export const createSignInExperienceQueries = (pool: CommonQueryMethods) => { const findDefaultSignInExperience = async () => buildFindEntityByIdWithPool(pool)(SignInExperiences)(id); - return { updateDefaultSignInExperience, findDefaultSignInExperience }; + return { + updateDefaultSignInExperience, + findDefaultSignInExperience, + }; }; diff --git a/packages/core/src/routes-me/social.ts b/packages/core/src/routes-me/social.ts index 58146a546..c543c4262 100644 --- a/packages/core/src/routes-me/social.ts +++ b/packages/core/src/routes-me/social.ts @@ -20,13 +20,11 @@ export default function socialRoutes( ...[router, tenant]: RouterInitArgs ) { const { - libraries: { - connectors: { getLogtoConnectors, getLogtoConnectorById }, - }, queries: { users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity }, signInExperiences: { findDefaultSignInExperience }, }, + connectors: { getLogtoConnectors, getLogtoConnectorById }, } = tenant; router.get('/social/connectors', async (ctx, next) => { diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 9dca79d52..327ad7640 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -105,7 +105,9 @@ const usersLibraries = { const adminUserRoutes = await pickDefault(import('./admin-user.js')); describe('adminUserRoutes', () => { - const tenantContext = new MockTenant(undefined, mockedQueries, { users: usersLibraries }); + const tenantContext = new MockTenant(undefined, mockedQueries, undefined, { + users: usersLibraries, + }); const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext }); afterEach(() => { diff --git a/packages/core/src/routes/authn.test.ts b/packages/core/src/routes/authn.test.ts index 6b1c0759c..8cf1a8931 100644 --- a/packages/core/src/routes/authn.test.ts +++ b/packages/core/src/routes/authn.test.ts @@ -62,6 +62,7 @@ const usersLibraries = { const tenantContext = new MockTenant( createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), undefined, + undefined, { users: usersLibraries, socials: socialsLibraries } ); const { createRequester } = await import('#src/utils/test-utils.js'); diff --git a/packages/core/src/routes/connector.test.ts b/packages/core/src/routes/connector.test.ts index 94a207b97..e3c93a8ac 100644 --- a/packages/core/src/routes/connector.test.ts +++ b/packages/core/src/routes/connector.test.ts @@ -76,24 +76,24 @@ const tenantContext = new MockTenant( undefined, { connectors: connectorQueries }, { - signInExperiences: { removeUnavailableSocialConnectorTargets }, - connectors: { - getLogtoConnectors, - getLogtoConnectorById: async (connectorId: string) => { - const connectors = await getLogtoConnectors(); - const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId); - assertThat( - connector, - new RequestError({ - code: 'entity.not_found', - connectorId, - status: 404, - }) - ); + getLogtoConnectors, + getLogtoConnectorById: async (connectorId: string) => { + const connectors = await getLogtoConnectors(); + const connector = connectors.find(({ dbEntry }) => dbEntry.id === connectorId); + assertThat( + connector, + new RequestError({ + code: 'entity.not_found', + connectorId, + status: 404, + }) + ); - return connector; - }, + return connector; }, + }, + { + signInExperiences: { removeUnavailableSocialConnectorTargets }, } ); diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index 4002c8c00..440551c9d 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -21,7 +21,7 @@ import type { AuthedRouter, RouterInitArgs } from './types.js'; const generateConnectorId = buildIdGenerator(12); export default function connectorRoutes( - ...[router, { queries, libraries }]: RouterInitArgs + ...[router, { queries, connectors, libraries }]: RouterInitArgs ) { const { findConnectorById, @@ -31,8 +31,8 @@ export default function connectorRoutes( insertConnector, updateConnector, } = queries.connectors; + const { getLogtoConnectorById, getLogtoConnectors } = connectors; const { - connectors: { getLogtoConnectorById, getLogtoConnectors }, signInExperiences: { removeUnavailableSocialConnectorTargets }, } = libraries; diff --git a/packages/core/src/routes/connector.update.test.ts b/packages/core/src/routes/connector.update.test.ts index c87c1b375..0da984db7 100644 --- a/packages/core/src/routes/connector.update.test.ts +++ b/packages/core/src/routes/connector.update.test.ts @@ -44,10 +44,10 @@ const tenantContext = new MockTenant( undefined, { connectors: { updateConnector } }, { - connectors: { - getLogtoConnectors, - getLogtoConnectorById, - }, + getLogtoConnectors, + getLogtoConnectorById, + }, + { signInExperiences: { // eslint-disable-next-line @typescript-eslint/no-empty-function removeUnavailableSocialConnectorTargets: async () => {}, diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 6ca2cb2da..f026a572d 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -56,7 +56,8 @@ describe('submit action', () => { const tenant = new MockTenant( undefined, { users: userQueries, signInExperiences: { updateDefaultSignInExperience: jest.fn() } }, - { users: userLibraries, connectors: { getLogtoConnectorById } } + { getLogtoConnectorById }, + { users: userLibraries } ); const ctx = { ...createContextWithRouteParameters(), diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 1a81e4d08..32d5a016c 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -10,6 +10,7 @@ import { } from '@logto/schemas'; import { conditional, conditionalArray } from '@silverhand/essentials'; +import { wellKnownCache } from '#src/caches/well-known.js'; import { EnvSet } from '#src/env-set/index.js'; import type { ConnectorLibrary } from '#src/libraries/connector.js'; import { assignInteractionResults } from '#src/libraries/session.js'; @@ -149,7 +150,7 @@ const parseUserProfile = async ( export default async function submitInteraction( interaction: VerifiedInteractionResult, ctx: WithInteractionDetailsContext, - { provider, libraries, queries }: TenantContext, + { provider, libraries, connectors, queries, id: tenantId }: TenantContext, log?: LogEntry ) { const { hasActiveUsers, findUserById, updateUserById } = queries.users; @@ -157,7 +158,6 @@ export default async function submitInteraction( const { users: { generateUserId, insertUser }, - connectors, } = libraries; const { event, profile } = interaction; @@ -192,6 +192,10 @@ export default async function submitInteraction( await updateDefaultSignInExperience({ signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn, }); + + // Normally we don't need to manually invalidate TTL cache. + // This is for better OSS onboarding experience. + wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']); } await assignInteractionResults(ctx, provider, { login: { accountId: id } }); diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index 09dd93c5b..3aec98a74 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -101,21 +101,21 @@ const tenantContext = new MockTenant( createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), undefined, { - connectors: { - getLogtoConnectorById: async (connectorId: string) => { - const connector = await getLogtoConnectorByIdHelper(connectorId); + getLogtoConnectorById: async (connectorId: string) => { + const connector = await getLogtoConnectorByIdHelper(connectorId); - if (connector.type !== ConnectorType.Social) { - throw new RequestError({ - code: 'entity.not_found', - status: 404, - }); - } + if (connector.type !== ConnectorType.Social) { + throw new RequestError({ + code: 'entity.not_found', + status: 404, + }); + } - // @ts-expect-error - return connector as LogtoConnector; - }, + // @ts-expect-error + return connector as LogtoConnector; }, + }, + { signInExperiences: { getSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience), }, diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 2d02c097e..9dda938dc 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -49,7 +49,7 @@ export type RouterContext = T extends Router ? Contex export default function interactionRoutes( ...[anonymousRouter, tenant]: RouterInitArgs ) { - const { provider, queries, libraries } = tenant; + const { provider, queries, libraries, id: tenantId } = tenant; const router = // @ts-expect-error for good koa types // eslint-disable-next-line no-restricted-syntax @@ -68,7 +68,7 @@ export default function interactionRoutes( profile: profileGuard.optional(), }), }), - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), async (ctx, next) => { const { event, identifier, profile } = ctx.guard.body; const { signInExperience, createLog } = ctx; @@ -118,7 +118,7 @@ export default function interactionRoutes( router.put( `${interactionPrefix}/event`, koaGuard({ body: z.object({ event: eventGuard }) }), - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), async (ctx, next) => { const { event } = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -156,7 +156,7 @@ export default function interactionRoutes( koaGuard({ body: identifierPayloadGuard, }), - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), async (ctx, next) => { const identifierPayload = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -193,7 +193,7 @@ export default function interactionRoutes( koaGuard({ body: profileGuard, }), - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), async (ctx, next) => { const profilePayload = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -230,7 +230,7 @@ export default function interactionRoutes( koaGuard({ body: profileGuard, }), - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), async (ctx, next) => { const profilePayload = ctx.guard.body; const { signInExperience, interactionDetails, createLog } = ctx; @@ -283,7 +283,7 @@ export default function interactionRoutes( // Submit Interaction router.post( `${interactionPrefix}/submit`, - koaInteractionSie(libraries.signInExperiences), + koaInteractionSie(libraries.signInExperiences, tenantId), koaInteractionHooks(tenant), async (ctx, next) => { const { interactionDetails, createLog } = ctx; diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts index 8cd0afa2e..322b997ab 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts @@ -1,7 +1,9 @@ import type { SignInExperience } from '@logto/schemas'; import type { MiddlewareType } from 'koa'; +import { wellKnownCache } from '#src/caches/well-known.js'; import type { SignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; +import { noCache } from '#src/utils/request.js'; import type { WithInteractionDetailsContext } from './koa-interaction-details.js'; @@ -9,14 +11,15 @@ export type WithInteractionSieContext = WithInteractionDetailsContext< signInExperience: SignInExperience; }; -export default function koaInteractionSie({ - getSignInExperience, -}: SignInExperienceLibrary): MiddlewareType< - StateT, - WithInteractionSieContext, - ResponseT -> { +export default function koaInteractionSie( + { getSignInExperience }: SignInExperienceLibrary, + tenantId: string +): MiddlewareType, ResponseT> { return async (ctx, next) => { + if (noCache(ctx.headers)) { + wellKnownCache.invalidate(tenantId, ['sie']); + } + const signInExperience = await getSignInExperience(); ctx.signInExperience = signInExperience; diff --git a/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts b/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts index a2c8b4009..dbfcb1fe2 100644 --- a/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts +++ b/packages/core/src/routes/interaction/utils/find-user-by-identifier.test.ts @@ -18,7 +18,7 @@ const tenantContext = new MockTenant( { users: queries, }, - { connectors: { getLogtoConnectorById } } + { getLogtoConnectorById } ); const findUserByIdentifier = await pickDefault(import('./find-user-by-identifier.js')); diff --git a/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts b/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts index d4ca483ac..efc523b3d 100644 --- a/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts +++ b/packages/core/src/routes/interaction/utils/find-user-by-identifier.ts @@ -3,12 +3,12 @@ import type TenantContext from '#src/tenants/TenantContext.js'; import type { UserIdentity } from '../types/index.js'; export default async function findUserByIdentifier( - { queries, libraries }: TenantContext, + { queries, connectors }: TenantContext, identity: UserIdentity ) { const { findUserByEmail, findUserByUsername, findUserByPhone, findUserByIdentity } = queries.users; - const { getLogtoConnectorById } = libraries.connectors; + const { getLogtoConnectorById } = connectors; if ('username' in identity) { return findUserByUsername(identity.username); diff --git a/packages/core/src/routes/interaction/utils/social-verification.test.ts b/packages/core/src/routes/interaction/utils/social-verification.test.ts index 01508ceb8..19eba8280 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.test.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.test.ts @@ -11,7 +11,9 @@ const { mockEsm } = createMockUtils(jest); const getUserInfoByAuthCode = jest.fn().mockResolvedValue({ id: 'foo' }); -const tenant = new MockTenant(undefined, undefined, { socials: { getUserInfoByAuthCode } }); +const tenant = new MockTenant(undefined, undefined, undefined, { + socials: { getUserInfoByAuthCode }, +}); mockEsm('#src/libraries/connector.js', () => ({ getLogtoConnectorById: jest.fn().mockResolvedValue({ diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 10ee4d633..b88b7be23 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -14,12 +14,10 @@ import type { SocialAuthorizationUrlPayload } from '../types/index.js'; export const createSocialAuthorizationUrl = async ( ctx: WithLogContext, - { provider, libraries }: TenantContext, + { provider, connectors }: TenantContext, payload: SocialAuthorizationUrlPayload ) => { - const { - connectors: { getLogtoConnectorById }, - } = libraries; + const { getLogtoConnectorById } = connectors; const { connectorId, state, redirectUri } = payload; assertThat(state && redirectUri, 'session.insufficient_info'); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.profile-registered.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification.profile-registered.test.ts index a13d88611..8917c333c 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification.profile-registered.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification.profile-registered.test.ts @@ -21,11 +21,7 @@ const getLogtoConnectorById = jest.fn().mockResolvedValue({ metadata: { target: 'logto' }, }); -const tenantContext = new MockTenant( - undefined, - { users: userQueries }, - { connectors: { getLogtoConnectorById } } -); +const tenantContext = new MockTenant(undefined, { users: userQueries }, { getLogtoConnectorById }); const verifyProfile = await pickDefault(import('./profile-verification.js')); const identifiers: Identifier[] = [ diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.protected-identifier.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification.protected-identifier.test.ts index 420dc87b6..36b85cdb1 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification.protected-identifier.test.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification.protected-identifier.test.ts @@ -19,11 +19,9 @@ const tenantContext = new MockTenant( }, }, { - connectors: { - getLogtoConnectorById: jest.fn().mockResolvedValue({ - metadata: { target: 'logto' }, - }), - }, + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { target: 'logto' }, + }), } ); const verifyProfile = await pickDefault(import('./profile-verification.js')); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.ts b/packages/core/src/routes/interaction/verifications/profile-verification.ts index fef88d309..066b40ce0 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification.ts @@ -58,7 +58,7 @@ const verifyProfileIdentifiers = ( }; const verifyProfileNotRegisteredByOtherUserAccount = async ( - { queries, libraries }: TenantContext, + { queries, connectors }: TenantContext, { username, email, phone, connectorId }: Profile, identifiers: Identifier[] = [] ) => { @@ -97,7 +97,7 @@ const verifyProfileNotRegisteredByOtherUserAccount = async ( if (connectorId) { const { metadata: { target }, - } = await libraries.connectors.getLogtoConnectorById(connectorId); + } = await connectors.getLogtoConnectorById(connectorId); const socialIdentifier = identifiers.find( (identifier): identifier is SocialIdentifier => identifier.key === 'social' diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts index caa019c17..5f0fd92fc 100644 --- a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts @@ -11,11 +11,9 @@ const { mockEsmDefault } = createMockUtils(jest); const findUserByIdentifier = mockEsmDefault('../utils/find-user-by-identifier.js', () => jest.fn()); -const tenant = new MockTenant( - undefined, - {}, - { socials: { findSocialRelatedUser: jest.fn().mockResolvedValue(null) } } -); +const tenant = new MockTenant(undefined, undefined, undefined, { + socials: { findSocialRelatedUser: jest.fn().mockResolvedValue(null) }, +}); const verifyUserAccount = await pickDefault(import('./user-identity-verification.js')); diff --git a/packages/core/src/routes/resource.test.ts b/packages/core/src/routes/resource.test.ts index 9614c3292..a6ef70388 100644 --- a/packages/core/src/routes/resource.test.ts +++ b/packages/core/src/routes/resource.test.ts @@ -52,7 +52,7 @@ mockEsm('@logto/core-kit', () => ({ buildIdGenerator: () => () => 'randomId', })); -const tenantContext = new MockTenant(undefined, { scopes, resources }, libraries); +const tenantContext = new MockTenant(undefined, { scopes, resources }, undefined, libraries); const resourceRoutes = await pickDefault(import('./resource.js')); diff --git a/packages/core/src/routes/sign-in-experience/guard.test.ts b/packages/core/src/routes/sign-in-experience/guard.test.ts index b5c29cf67..0ed434363 100644 --- a/packages/core/src/routes/sign-in-experience/guard.test.ts +++ b/packages/core/src/routes/sign-in-experience/guard.test.ts @@ -20,6 +20,7 @@ const tenantContext = new MockTenant( }), }, }, + undefined, { signInExperiences: { validateLanguageInfo, diff --git a/packages/core/src/routes/sign-in-experience/index.test.ts b/packages/core/src/routes/sign-in-experience/index.test.ts index cd6e076ae..b4101bd7b 100644 --- a/packages/core/src/routes/sign-in-experience/index.test.ts +++ b/packages/core/src/routes/sign-in-experience/index.test.ts @@ -61,10 +61,8 @@ const tenantContext = new MockTenant( customPhrases: { findAllCustomLanguageTags: async () => [] }, connectors: { deleteConnectorById: mockDeleteConnectorById }, }, - { - signInExperiences: { validateLanguageInfo }, - connectors: { getLogtoConnectors: mockGetLogtoConnectors }, - } + { getLogtoConnectors: mockGetLogtoConnectors }, + { signInExperiences: { validateLanguageInfo } } ); const signInExperiencesRoutes = await pickDefault(import('./index.js')); diff --git a/packages/core/src/routes/sign-in-experience/index.ts b/packages/core/src/routes/sign-in-experience/index.ts index f260aeffa..ae7467e3f 100644 --- a/packages/core/src/routes/sign-in-experience/index.ts +++ b/packages/core/src/routes/sign-in-experience/index.ts @@ -8,14 +8,14 @@ import koaGuard from '#src/middleware/koa-guard.js'; import type { AuthedRouter, RouterInitArgs } from '../types.js'; export default function signInExperiencesRoutes( - ...[router, { queries, libraries }]: RouterInitArgs + ...[router, { queries, libraries, connectors }]: RouterInitArgs ) { const { findDefaultSignInExperience, updateDefaultSignInExperience } = queries.signInExperiences; const { deleteConnectorById } = queries.connectors; const { signInExperiences: { validateLanguageInfo }, - connectors: { getLogtoConnectors }, } = libraries; + const { getLogtoConnectors } = connectors; /** * As we only support single signInExperience settings for V1 @@ -62,6 +62,8 @@ export default function signInExperiencesRoutes( ) ); + console.log('???', socialSignInConnectorTargets, filteredSocialSignInConnectorTargets); + if (signUp) { validateSignUp(signUp, connectors); } diff --git a/packages/core/src/routes/verification-code.test.ts b/packages/core/src/routes/verification-code.test.ts index c890903f7..0e9f4e517 100644 --- a/packages/core/src/routes/verification-code.test.ts +++ b/packages/core/src/routes/verification-code.test.ts @@ -23,13 +23,9 @@ const passcodeQueries = await mockEsmWithActual('#src/queries/passcode.js', () = const verificationCodeRoutes = await pickDefault(import('./verification-code.js')); describe('Generic verification code flow triggered by management API', () => { - const tenantContext = new MockTenant( - undefined, - { passcodes: passcodeQueries }, - { - passcodes: passcodeLibraries, - } - ); + const tenantContext = new MockTenant(undefined, { passcodes: passcodeQueries }, undefined, { + passcodes: passcodeLibraries, + }); const verificationCodeRequest = createRequester({ authedRoutes: verificationCodeRoutes, tenantContext, diff --git a/packages/core/src/routes/well-known.phrases.content-language.test.ts b/packages/core/src/routes/well-known.phrases.content-language.test.ts index f09b21f79..19cf525bc 100644 --- a/packages/core/src/routes/well-known.phrases.content-language.test.ts +++ b/packages/core/src/routes/well-known.phrases.content-language.test.ts @@ -4,6 +4,7 @@ import { pickDefault } from '@logto/shared/esm'; import { trTrTag, zhCnTag, zhHkTag } from '#src/__mocks__/custom-phrase.js'; import { mockSignInExperience } from '#src/__mocks__/index.js'; +import { wellKnownCache } from '#src/caches/well-known.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; @@ -29,6 +30,7 @@ const tenantContext = new MockTenant( customPhrases: { findAllCustomLanguageTags: async () => [trTrTag, zhCnTag] }, signInExperiences: { findDefaultSignInExperience }, }, + undefined, { phrases: { getPhrases: jest.fn().mockResolvedValue(en) } } ); @@ -40,6 +42,7 @@ const phraseRequest = createRequester({ }); afterEach(() => { + wellKnownCache.invalidateAll(tenantContext.id); jest.clearAllMocks(); }); diff --git a/packages/core/src/routes/well-known.phrases.test.ts b/packages/core/src/routes/well-known.phrases.test.ts index eb43a0fc2..1c5b8a482 100644 --- a/packages/core/src/routes/well-known.phrases.test.ts +++ b/packages/core/src/routes/well-known.phrases.test.ts @@ -4,6 +4,7 @@ import { pickDefault, createMockUtils } from '@logto/shared/esm'; import { zhCnTag } from '#src/__mocks__/custom-phrase.js'; import { mockSignInExperience } from '#src/__mocks__/index.js'; +import { wellKnownCache } from '#src/caches/well-known.js'; import Queries from '#src/tenants/Queries.js'; import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -46,6 +47,7 @@ const getPhrases = jest.fn(async () => zhCN); const tenantContext = new MockTenant( createMockProvider(), { customPhrases, signInExperiences: { findDefaultSignInExperience } }, + undefined, { phrases: { getPhrases } } ); @@ -57,11 +59,12 @@ const phraseRequest = createRequester({ tenantContext, }); -describe('when the application is not admin-console', () => { - afterEach(() => { - jest.clearAllMocks(); - }); +afterEach(() => { + wellKnownCache.invalidateAll(tenantContext.id); + jest.clearAllMocks(); +}); +describe('when the application is not admin-console', () => { it('should call findDefaultSignInExperience', async () => { await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(findDefaultSignInExperience).toBeCalledTimes(1); @@ -123,4 +126,16 @@ describe('when the application is not admin-console', () => { ); expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]); }); + + it('should use cache for continuous requests', async () => { + const [response1, response2, response3] = await Promise.all([ + phraseRequest.get('/.well-known/phrases'), + phraseRequest.get('/.well-known/phrases'), + phraseRequest.get('/.well-known/phrases'), + ]); + expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1); + expect(findAllCustomLanguageTags).toHaveBeenCalledTimes(1); + expect(response1.body).toStrictEqual(response2.body); + expect(response1.body).toStrictEqual(response3.body); + }); }); diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known.test.ts index 6cefa6861..e3e4dac85 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known.test.ts @@ -10,6 +10,7 @@ import { mockWechatConnector, mockWechatNativeConnector, } from '#src/__mocks__/index.js'; +import { wellKnownCache } from '#src/caches/well-known.js'; const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); @@ -32,34 +33,36 @@ const { createMockProvider } = await import('#src/test-utils/oidc-provider.js'); const { MockTenant } = await import('#src/test-utils/tenant.js'); const { createRequester } = await import('#src/utils/test-utils.js'); +const provider = createMockProvider(); +const getLogtoConnectors = jest.fn(async () => { + return [ + mockAliyunDmConnector, + mockAliyunSmsConnector, + mockFacebookConnector, + mockGithubConnector, + mockGoogleConnector, + mockWechatConnector, + mockWechatNativeConnector, + ]; +}); +const tenantContext = new MockTenant( + provider, + { + signInExperiences: sieQueries, + users: { hasActiveUsers: jest.fn().mockResolvedValue(true) }, + }, + { getLogtoConnectors } +); + describe('GET /.well-known/sign-in-exp', () => { afterEach(() => { + wellKnownCache.invalidateAll(tenantContext.id); jest.clearAllMocks(); }); - const provider = createMockProvider(); const sessionRequest = createRequester({ anonymousRoutes: wellKnownRoutes, - tenantContext: new MockTenant( - provider, - { - signInExperiences: sieQueries, - users: { hasActiveUsers: jest.fn().mockResolvedValue(true) }, - }, - { - connectors: { - getLogtoConnectors: jest.fn(async () => [ - mockAliyunDmConnector, - mockAliyunSmsConnector, - mockFacebookConnector, - mockGithubConnector, - mockGoogleConnector, - mockWechatConnector, - mockWechatNativeConnector, - ]), - }, - } - ), + tenantContext, middlewares: [ async (ctx, next) => { ctx.addLogContext = jest.fn(); @@ -96,4 +99,16 @@ describe('GET /.well-known/sign-in-exp', () => { ], }); }); + + it('should use cache for continuous requests', async () => { + const [response1, response2, response3] = await Promise.all([ + sessionRequest.get('/.well-known/sign-in-exp'), + sessionRequest.get('/.well-known/sign-in-exp'), + sessionRequest.get('/.well-known/sign-in-exp'), + ]); + expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1); + expect(getLogtoConnectors).toHaveBeenCalledTimes(1); + expect(response1.body).toStrictEqual(response2.body); + expect(response2.body).toStrictEqual(response3.body); + }); }); diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 463355608..83b18d8c8 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -1,30 +1,27 @@ -import type { ConnectorMetadata } from '@logto/connector-kit'; -import { ConnectorType } from '@logto/connector-kit'; import { isBuiltInLanguageTag } from '@logto/phrases-ui'; import { adminTenantId } from '@logto/schemas'; -import { object, string } from 'zod'; +import { conditionalArray } from '@silverhand/essentials'; +import { z } from 'zod'; +import { wellKnownCache } from '#src/caches/well-known.js'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import detectLanguage from '#src/i18n/detect-language.js'; +import { guardFullSignInExperience } from '#src/libraries/sign-in-experience/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { noCache } from '#src/utils/request.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; export default function wellKnownRoutes( - ...[router, { queries, libraries, id }]: RouterInitArgs + ...[router, { libraries, id: tenantId }]: RouterInitArgs ) { const { - customPhrases: { findAllCustomLanguageTags }, - signInExperiences: { findDefaultSignInExperience }, - } = queries; - const { - signInExperiences: { getSignInExperience }, - connectors: { getLogtoConnectors }, - phrases: { getPhrases }, + signInExperiences: { getSignInExperience, getFullSignInExperience }, + phrases: { getPhrases, getAllCustomLanguageTags }, } = libraries; - if (id === adminTenantId) { + if (tenantId === adminTenantId) { router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => { if (!ctx.params.tenantId) { throw new RequestError('request.invalid_input'); @@ -38,59 +35,48 @@ export default function wellKnownRoutes( }); } - router.get('/.well-known/sign-in-exp', async (ctx, next) => { - const [signInExperience, logtoConnectors] = await Promise.all([ - getSignInExperience(), - getLogtoConnectors(), - ]); + router.get( + '/.well-known/sign-in-exp', + koaGuard({ response: guardFullSignInExperience, status: 200 }), + async (ctx, next) => { + if (noCache(ctx.headers)) { + wellKnownCache.invalidate(tenantId, ['sie', 'sie-full']); + } - const forgotPassword = { - phone: logtoConnectors.some(({ type }) => type === ConnectorType.Sms), - email: logtoConnectors.some(({ type }) => type === ConnectorType.Email), - }; + ctx.body = await getFullSignInExperience(); - const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce< - Array - >((previous, connectorTarget) => { - const connectors = logtoConnectors.filter( - ({ metadata: { target } }) => target === connectorTarget - ); - - return [ - ...previous, - ...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })), - ]; - }, []); - - ctx.body = { - ...signInExperience, - socialConnectors, - forgotPassword, - }; - - return next(); - }); + return next(); + } + ); router.get( '/.well-known/phrases', koaGuard({ - query: object({ - lng: string().optional(), + query: z.object({ + lng: z.string().optional(), }), + response: z.record(z.string().or(z.record(z.unknown()))), + status: 200, }), async (ctx, next) => { + if (noCache(ctx.headers)) { + wellKnownCache.invalidate(tenantId, ['sie', 'phrases-lng-tags', 'phrases']); + } + const { query: { lng }, } = ctx.guard; const { languageInfo: { autoDetect, fallbackLanguage }, - } = await findDefaultSignInExperience(); + } = await getSignInExperience(); - const targetLanguage = lng ? [lng] : []; - const detectedLanguages = autoDetect ? detectLanguage(ctx) : []; - const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage]; - const customLanguages = await findAllCustomLanguageTags(); + const acceptableLanguages = conditionalArray( + lng, + autoDetect && detectLanguage(ctx), + fallbackLanguage + ); + const customLanguages = await getAllCustomLanguageTags(); const language = acceptableLanguages.find( (tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag) diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 9ddef88ab..88b1ee828 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -1,5 +1,5 @@ import { createApplicationLibrary } from '#src/libraries/application.js'; -import { createConnectorLibrary } from '#src/libraries/connector.js'; +import type { ConnectorLibrary } from '#src/libraries/connector.js'; import { createHookLibrary } from '#src/libraries/hook.js'; import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPhraseLibrary } from '#src/libraries/phrase.js'; @@ -12,10 +12,9 @@ import { createVerificationStatusLibrary } from '#src/libraries/verification-sta import type Queries from './Queries.js'; export default class Libraries { - connectors = createConnectorLibrary(this.queries); users = createUserLibrary(this.queries); - signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors); - phrases = createPhraseLibrary(this.queries); + signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors, this.tenantId); + phrases = createPhraseLibrary(this.queries, this.tenantId); resources = createResourceLibrary(this.queries); hooks = createHookLibrary(this.queries); socials = createSocialLibrary(this.queries, this.connectors); @@ -23,5 +22,10 @@ export default class Libraries { applications = createApplicationLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries); - constructor(private readonly queries: Queries) {} + constructor( + public readonly tenantId: string, + private readonly queries: Queries, + // Explicitly passing connector library to eliminate dependency issue + private readonly connectors: ConnectorLibrary + ) {} } diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index ed018c990..2f75c8f43 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -8,6 +8,7 @@ import mount from 'koa-mount'; import type Provider from 'oidc-provider'; import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js'; +import { createConnectorLibrary } from '#src/libraries/connector.js'; import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js'; import koaErrorHandler from '#src/middleware/koa-error-handler.js'; @@ -38,15 +39,18 @@ export default class Tenant implements TenantContext { #onRequestEmpty?: () => Promise; public readonly provider: Provider; - public readonly queries: Queries; - public readonly libraries: Libraries; public readonly run: MiddlewareType; private readonly app: Koa; - private constructor(public readonly envSet: EnvSet, public readonly id: string) { - const queries = new Queries(envSet.pool); - const libraries = new Libraries(queries); + // eslint-disable-next-line max-params + private constructor( + public readonly envSet: EnvSet, + public readonly id: string, + public readonly queries = new Queries(envSet.pool), + public readonly connectors = createConnectorLibrary(queries), + public readonly libraries = new Libraries(id, queries, connectors) + ) { const isAdminTenant = id === adminTenantId; const mountedApps = [ ...Object.values(UserApps), @@ -54,8 +58,6 @@ export default class Tenant implements TenantContext { ]; this.envSet = envSet; - this.queries = queries; - this.libraries = libraries; // Init app const app = new Koa(); @@ -69,13 +71,14 @@ export default class Tenant implements TenantContext { app.use(koaCompress()); // Mount OIDC - const provider = initOidc(envSet, queries, libraries); + const provider = initOidc(id, envSet, queries, libraries); app.use(mount('/oidc', provider.app)); const tenantContext: TenantContext = { id, provider, queries, + connectors, libraries, envSet, }; diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts index 35d6c2bc6..e9fc4fab1 100644 --- a/packages/core/src/tenants/TenantContext.ts +++ b/packages/core/src/tenants/TenantContext.ts @@ -1,6 +1,7 @@ import type Provider from 'oidc-provider'; import type { EnvSet } from '#src/env-set/index.js'; +import type { ConnectorLibrary } from '#src/libraries/connector.js'; import type Libraries from './Libraries.js'; import type Queries from './Queries.js'; @@ -10,5 +11,6 @@ export default abstract class TenantContext { public abstract readonly envSet: EnvSet; public abstract readonly provider: Provider; public abstract readonly queries: Queries; + public abstract readonly connectors: ConnectorLibrary; public abstract readonly libraries: Libraries; } diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index af5881fb5..8ad4c144b 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -1,5 +1,7 @@ import { createMockPool, createMockQueryResult } from 'slonik'; +import type { ConnectorLibrary } from '#src/libraries/connector.js'; +import { createConnectorLibrary } from '#src/libraries/connector.js'; import Libraries from '#src/tenants/Libraries.js'; import Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; @@ -46,15 +48,18 @@ export class MockTenant implements TenantContext { public id = 'mock_id'; public envSet = mockEnvSet; public queries: Queries; + public connectors: ConnectorLibrary; public libraries: Libraries; constructor( public provider = createMockProvider(), queriesOverride?: Partial2, + connectorsOverride?: Partial, librariesOverride?: Partial2 ) { this.queries = new MockQueries(queriesOverride); - this.libraries = new Libraries(this.queries); + this.connectors = { ...createConnectorLibrary(this.queries), ...connectorsOverride }; + this.libraries = new Libraries(this.id, this.queries, this.connectors); this.setPartial('libraries', librariesOverride); } diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts new file mode 100644 index 000000000..e1aef234a --- /dev/null +++ b/packages/core/src/utils/request.ts @@ -0,0 +1,5 @@ +import type { IncomingHttpHeaders } from 'http'; + +export const noCache = (headers: IncomingHttpHeaders): boolean => + headers['cache-control']?.split(',').some((value) => value.trim().toLowerCase() === 'no-cache') ?? + false; diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index de9e3695e..be5b8d5d3 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -2,7 +2,10 @@ import { got } from 'got'; import { logtoConsoleUrl, logtoUrl } from '#src/constants.js'; -const api = got.extend({ prefixUrl: new URL('/api', logtoUrl) }); +const api = got.extend({ + prefixUrl: new URL('/api', logtoUrl), + headers: { 'cache-control': 'no-cache' }, +}); export default api; @@ -13,7 +16,10 @@ export const authedAdminApi = api.extend({ }, }); -export const adminTenantApi = got.extend({ prefixUrl: new URL('/api', logtoConsoleUrl) }); +export const adminTenantApi = got.extend({ + prefixUrl: new URL('/api', logtoConsoleUrl), + headers: { 'cache-control': 'no-cache' }, +}); export const authedAdminTenantApi = adminTenantApi.extend({ headers: { diff --git a/packages/integration-tests/src/tests/api/well-known.test.ts b/packages/integration-tests/src/tests/api/well-known.test.ts index 388f543c4..df49a24b3 100644 --- a/packages/integration-tests/src/tests/api/well-known.test.ts +++ b/packages/integration-tests/src/tests/api/well-known.test.ts @@ -1,7 +1,8 @@ import type { SignInExperience } from '@logto/schemas'; -import { adminTenantApi } from '#src/api/api.js'; +import { adminTenantApi, authedAdminApi } from '#src/api/api.js'; import { api } from '#src/api/index.js'; +import { generateUserId } from '#src/utils.js'; describe('.well-known api', () => { it('get /.well-known/sign-in-exp for console', async () => { @@ -33,4 +34,19 @@ describe('.well-known api', () => { // Should support sign-in and register expect(response).toMatchObject({ signInMode: 'SignInAndRegister' }); }); + + it('should use cached version if no-cache header is not present', async () => { + const response1 = await api.get('.well-known/sign-in-exp').json(); + + const randomId = generateUserId(); + const customContent = { foo: randomId }; + await authedAdminApi.patch('sign-in-exp', { json: { customContent } }).json(); + + const response2 = await api + .get('.well-known/sign-in-exp', { headers: { 'cache-control': '' } }) + .json(); + + expect(response2.customContent.foo).not.toBe(randomId); + expect(response2).toStrictEqual(response1); + }); }); diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 8d0e61395..685466dc2 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './function.js'; export * from './object.js'; export { default as findPackage } from './find-package.js'; +export * from './ttl-cache.js'; diff --git a/packages/shared/src/utils/ttl-cache.test.ts b/packages/shared/src/utils/ttl-cache.test.ts new file mode 100644 index 000000000..d9b69209f --- /dev/null +++ b/packages/shared/src/utils/ttl-cache.test.ts @@ -0,0 +1,84 @@ +import { TtlCache } from './ttl-cache.js'; + +const { jest } = import.meta; + +describe('TtlCache', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return cached value after a long time if ttl is not set', () => { + jest.setSystemTime(0); + + const cache = new TtlCache(); + const someObject = Object.freeze({ foo: 'bar', baz: 123 }); + + cache.set('foo', someObject); + + jest.setSystemTime(100_000_000); + expect(cache.get('foo')).toBe(someObject); + expect(cache.has('foo')).toBe(true); + }); + + it('should return cached value and honor ttl', () => { + jest.setSystemTime(0); + + const cache = new TtlCache(100); + const someObject = Object.freeze({ foo: 'bar', baz: 123 }); + + cache.set(123, someObject); + cache.set('foo', someObject, 99); + + jest.setSystemTime(100); + expect(cache.get(123)).toBe(someObject); + expect(cache.has(123)).toBe(true); + expect(cache.get('123')).toBe(undefined); + expect(cache.has('123')).toBe(false); + expect(cache.get('foo')).toBe(undefined); + expect(cache.has('foo')).toBe(false); + + jest.setSystemTime(101); + expect(cache.get(123)).toBe(undefined); + expect(cache.has(123)).toBe(false); + }); + + it('should be able to delete value before ttl', () => { + const cache = new TtlCache(100); + const someObject = Object.freeze({ foo: 'bar', baz: 123 }); + + cache.set('foo', someObject); + cache.delete('foo'); + cache.delete('bar'); + + expect(cache.get('foo')).toBe(undefined); + expect(cache.has('foo')).toBe(false); + }); + + it('should be able to clear all values', () => { + const cache = new TtlCache(100); + const someObject = Object.freeze({ foo: 'bar', baz: 123 }); + + cache.set('foo', someObject); + cache.set('bar', someObject); + cache.set(123, 456); + cache.clear(); + + expect(cache.get('foo')).toBe(undefined); + expect(cache.has('foo')).toBe(false); + expect(cache.get('bar')).toBe(undefined); + expect(cache.has('bar')).toBe(false); + expect(cache.get(123)).toBe(undefined); + expect(cache.has(123)).toBe(false); + }); + + it('should throw undefined when value is undefined', () => { + const cache = new TtlCache(); + expect(() => { + cache.set(1, undefined); + }).toThrow(TypeError); + }); +}); diff --git a/packages/shared/src/utils/ttl-cache.ts b/packages/shared/src/utils/ttl-cache.ts new file mode 100644 index 000000000..28beba2d7 --- /dev/null +++ b/packages/shared/src/utils/ttl-cache.ts @@ -0,0 +1,46 @@ +export class TtlCache { + data = new Map(); + expiration = new Map(); + + constructor(public readonly ttl = Number.POSITIVE_INFINITY) {} + + #purge(key: Key) { + const expiration = this.expiration.get(key); + + if (expiration !== undefined && expiration < Date.now()) { + this.delete(key); + } + } + + set(key: Key, value: Value, ttl = this.ttl) { + if (value === undefined) { + throw new TypeError('Value cannot be undefined'); + } + + this.expiration.set(key, Date.now() + ttl); + this.data.set(key, value); + } + + get(key: Key): Value | undefined { + this.#purge(key); + + return this.data.get(key); + } + + has(key: Key) { + this.#purge(key); + + return this.data.has(key); + } + + delete(key: Key) { + this.expiration.delete(key); + + return this.data.delete(key); + } + + clear() { + this.expiration.clear(); + this.data.clear(); + } +} diff --git a/packages/toolkit/connector-kit/src/types.ts b/packages/toolkit/connector-kit/src/types.ts index ab4f1773d..480c9f5a2 100644 --- a/packages/toolkit/connector-kit/src/types.ts +++ b/packages/toolkit/connector-kit/src/types.ts @@ -136,7 +136,7 @@ const connectorConfigFormItemGuard = z.discriminatedUnion('type', [ export type ConnectorConfigFormItem = z.infer; -const connectorMetadataGuard = z.object({ +export const connectorMetadataGuard = z.object({ id: z.string(), target: z.string(), platform: z.nativeEnum(ConnectorPlatform).nullable(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11706ad28..db0e80f62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,6 +401,7 @@ importers: nodemon: ^2.0.19 oidc-provider: ^8.0.0 openapi-types: ^12.0.0 + p-memoize: ^7.1.1 p-retry: ^5.1.2 pg-protocol: ^1.6.0 prettier: ^2.8.2 @@ -454,6 +455,7 @@ importers: lru-cache: 7.14.1 nanoid: 4.0.0 oidc-provider: 8.0.0 + p-memoize: 7.1.1 p-retry: 5.1.2 pg-protocol: 1.6.0 roarr: 7.11.0 @@ -10638,7 +10640,6 @@ packages: /mimic-fn/4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} - dev: true /mimic-response/3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} @@ -11233,6 +11234,14 @@ packages: aggregate-error: 3.1.0 dev: true + /p-memoize/7.1.1: + resolution: {integrity: sha512-DZ/bONJILHkQ721hSr/E9wMz5Am/OTJ9P6LhLFo2Tu+jL8044tgc9LwHO8g4PiaYePnlVVRAJcKmgy8J9MVFrA==} + engines: {node: '>=14.16'} + dependencies: + mimic-fn: 4.0.0 + type-fest: 3.5.2 + dev: false + /p-retry/5.1.2: resolution: {integrity: sha512-couX95waDu98NfNZV+i/iLt+fdVxmI7CbrrdC2uDWfPdUAApyxT4wmDlyOtR5KtTDmkDO0zDScDjDou9YHhd9g==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}