From f317a917c9451ae522f2f851f75f4e23957c11fd Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 11 Jan 2023 16:41:53 +0800 Subject: [PATCH] refactor: remove deprecated singleton instances --- .../src/commands/database/seed/oidc-config.ts | 9 +- packages/core/jest.setup.js | 28 ++++-- packages/core/src/app/init.ts | 8 +- .../core/src/database/find-entity-by-id.ts | 4 - packages/core/src/database/insert-into.ts | 4 - packages/core/src/database/row-count.ts | 5 -- packages/core/src/database/update-where.ts | 4 - packages/core/src/env-set/index.ts | 27 ++---- packages/core/src/env-set/oidc.ts | 2 - packages/core/src/index.ts | 8 +- .../src/libraries/__mocks__/logto-config.ts | 1 - packages/core/src/libraries/hook.ts | 1 + packages/core/src/libraries/session.ts | 9 +- packages/core/src/libraries/user.ts | 34 ++++++-- packages/core/src/middleware/koa-auth.test.ts | 53 +++++++----- packages/core/src/middleware/koa-auth.ts | 7 +- .../core/src/middleware/koa-error-handler.ts | 6 +- packages/core/src/middleware/koa-guard.ts | 4 +- .../core/src/middleware/koa-root-proxy.ts | 4 +- .../core/src/middleware/koa-spa-proxy.test.ts | 15 ++-- packages/core/src/middleware/koa-spa-proxy.ts | 6 +- .../src/middleware/koa-spa-session-guard.ts | 4 +- .../src/middleware/koa-welcome-proxy.test.ts | 19 ++--- .../core/src/middleware/koa-welcome-proxy.ts | 9 +- packages/core/src/oidc/adapter.test.ts | 7 +- packages/core/src/oidc/adapter.ts | 15 ++-- packages/core/src/oidc/init.test.ts | 7 +- packages/core/src/oidc/init.ts | 23 +++-- packages/core/src/oidc/utils.test.ts | 10 ++- packages/core/src/oidc/utils.ts | 7 +- .../core/src/queries/oidc-model-instance.ts | 14 ++- packages/core/src/queries/roles.ts | 16 ---- packages/core/src/queries/user.ts | 43 +--------- packages/core/src/queries/users-roles.ts | 11 --- packages/core/src/routes/admin-user.test.ts | 8 +- packages/core/src/routes/admin-user.ts | 14 ++- packages/core/src/routes/authn.test.ts | 4 +- packages/core/src/routes/authn.ts | 6 +- packages/core/src/routes/init.ts | 2 +- .../src/routes/interaction/consent.test.ts | 85 +++++++++++-------- .../core/src/routes/interaction/consent.ts | 9 +- packages/core/src/routes/interaction/index.ts | 4 +- .../mandatory-user-profile-validation.test.ts | 38 ++++----- .../mandatory-user-profile-validation.ts | 6 +- packages/core/src/tenants/Tenant.test.ts | 4 +- packages/core/src/tenants/Tenant.ts | 18 ++-- packages/core/src/tenants/TenantContext.ts | 2 + packages/core/src/tenants/index.ts | 14 ++- packages/core/src/test-utils/env-set.ts | 6 ++ packages/core/src/test-utils/tenant.ts | 2 + packages/schemas/src/types/logto-config.ts | 8 -- 51 files changed, 335 insertions(+), 319 deletions(-) create mode 100644 packages/core/src/test-utils/env-set.ts diff --git a/packages/cli/src/commands/database/seed/oidc-config.ts b/packages/cli/src/commands/database/seed/oidc-config.ts index f7cd46650..a5a5cfc35 100644 --- a/packages/cli/src/commands/database/seed/oidc-config.ts +++ b/packages/cli/src/commands/database/seed/oidc-config.ts @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises'; import type { LogtoOidcConfigType } from '@logto/schemas'; import { LogtoOidcConfigKey } from '@logto/schemas'; -import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; +import { getEnvAsStringArray } from '@silverhand/essentials'; import { generateOidcCookieKey, generateOidcPrivateKey } from '../utilities.js'; @@ -67,11 +67,4 @@ export const oidcConfigReaders: { return { value: keys.length > 0 ? keys : [generateOidcCookieKey()], fromEnv: keys.length > 0 }; }, - [LogtoOidcConfigKey.RefreshTokenReuseInterval]: async () => { - const envKey = 'OIDC_REFRESH_TOKEN_REUSE_INTERVAL'; - const raw = Number(getEnv(envKey)); - const value = Math.max(3, raw || 0); - - return { value, fromEnv: raw === value }; - }, }; diff --git a/packages/core/jest.setup.js b/packages/core/jest.setup.js index 218347344..1ee99e947 100644 --- a/packages/core/jest.setup.js +++ b/packages/core/jest.setup.js @@ -6,9 +6,26 @@ import { createMockUtils } from '@logto/shared/esm'; import { createMockQueryResult, createMockPool } from 'slonik'; const { jest } = import.meta; -const { mockEsm } = createMockUtils(jest); +const { mockEsm, mockEsmWithActual, mockEsmDefault } = createMockUtils(jest); -mockEsm('#src/env-set/index.js', () => ({ +process.env.DB_URL = 'postgres://mock.db.url'; +process.env.ENDPOINT = 'https://logto.test'; +process.env.NODE_ENV = 'test'; + +mockEsm('#src/libraries/logto-config.js', () => ({ + createLogtoConfigLibrary: () => ({ getOidcConfigs: () => ({}) }), +})); + +mockEsm('#src/env-set/check-alteration-state.js', () => ({ + checkAlterationState: () => true, +})); + +// eslint-disable-next-line unicorn/consistent-function-scoping +mockEsmDefault('#src/env-set/oidc.js', () => () => ({ + issuer: 'https://logto.test/oidc', +})); + +await mockEsmWithActual('#src/env-set/index.js', () => ({ MountedApps: { Api: 'api', Oidc: 'oidc', @@ -16,13 +33,8 @@ mockEsm('#src/env-set/index.js', () => ({ DemoApp: 'demo-app', Welcome: 'welcome', }, + // TODO: Remove after clean up of default env sets default: { - get values() { - return { - endpoint: 'https://logto.test', - adminConsoleUrl: 'https://logto.test/console', - }; - }, get oidc() { return { issuer: 'https://logto.test/oidc', diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 47f9e2d06..9df4c8fa4 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -5,11 +5,11 @@ import { deduplicate } from '@silverhand/essentials'; import chalk from 'chalk'; import type Koa from 'koa'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import { tenantPool, defaultTenant } from '#src/tenants/index.js'; const logListening = () => { - const { localhostUrl, endpoint } = envSet.values; + const { localhostUrl, endpoint } = EnvSet.values; for (const url of deduplicate([localhostUrl, endpoint])) { console.log(chalk.bold(chalk.green(`App is running at ${url}`))); @@ -19,12 +19,12 @@ const logListening = () => { export default async function initApp(app: Koa): Promise { app.use(async (ctx, next) => { // TODO: add multi-tenancy logic - const tenant = tenantPool.get(defaultTenant); + const tenant = await tenantPool.get(defaultTenant); return tenant.run(ctx, next); }); - const { isHttpsEnabled, httpsCert, httpsKey, port } = envSet.values; + const { isHttpsEnabled, httpsCert, httpsKey, port } = EnvSet.values; if (isHttpsEnabled && httpsCert && httpsKey) { http2 diff --git a/packages/core/src/database/find-entity-by-id.ts b/packages/core/src/database/find-entity-by-id.ts index ddb7f6c37..b335ee62e 100644 --- a/packages/core/src/database/find-entity-by-id.ts +++ b/packages/core/src/database/find-entity-by-id.ts @@ -3,7 +3,6 @@ import { convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; import { sql, NotFoundError } from 'slonik'; -import envSet from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import assertThat from '#src/utils/assert-that.js'; import { isKeyOf } from '#src/utils/schema.js'; @@ -39,6 +38,3 @@ export const buildFindEntityByIdWithPool = } }; }; - -/** @deprecated Will be removed soon. Use buildFindEntityByIdWithPool() factory instead. */ -export const buildFindEntityById = buildFindEntityByIdWithPool(envSet.pool); diff --git a/packages/core/src/database/insert-into.ts b/packages/core/src/database/insert-into.ts index fe7c5ea3a..2f1b445e4 100644 --- a/packages/core/src/database/insert-into.ts +++ b/packages/core/src/database/insert-into.ts @@ -10,7 +10,6 @@ import { has } from '@silverhand/essentials'; import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; -import envSet from '#src/env-set/index.js'; import { InsertionError } from '#src/errors/SlonikError/index.js'; import assertThat from '#src/utils/assert-that.js'; @@ -86,6 +85,3 @@ export const buildInsertIntoWithPool = return entry; }; }; - -/** @deprecated Will be removed soon. Use buildInsertIntoWithPool() factory instead. */ -export const buildInsertInto = buildInsertIntoWithPool(envSet.pool); diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index 3a4f52573..eed209802 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -1,14 +1,9 @@ import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; -import envSet from '#src/env-set/index.js'; - export const getTotalRowCountWithPool = (pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => pool.one<{ count: number }>(sql` select count(*) from ${table} `); - -/** @deprecated Will be removed soon. Use getTotalRowCountWithPool() factory instead. */ -export const getTotalRowCount = getTotalRowCountWithPool(envSet.pool); diff --git a/packages/core/src/database/update-where.ts b/packages/core/src/database/update-where.ts index 57bc5db22..e705b8152 100644 --- a/packages/core/src/database/update-where.ts +++ b/packages/core/src/database/update-where.ts @@ -6,7 +6,6 @@ import { notFalsy } from '@silverhand/essentials'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; -import envSet from '#src/env-set/index.js'; import { UpdateError } from '#src/errors/SlonikError/index.js'; import assertThat from '#src/utils/assert-that.js'; import { isKeyOf } from '#src/utils/schema.js'; @@ -72,6 +71,3 @@ export const buildUpdateWhereWithPool = return data; }; }; - -/** @deprecated Will be removed soon. Use buildUpdateWhereWithPool() factory instead. */ -export const buildUpdateWhere = buildUpdateWhereWithPool(envSet.pool); diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index d01e09a1c..bc5d5251d 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -51,8 +51,11 @@ const loadEnvValues = () => { }); }; -class EnvSet { - static envValues: ReturnType = loadEnvValues(); +export class EnvSet { + static values: ReturnType = loadEnvValues(); + static get isTest() { + return this.values.isTest; + } #pool: Optional; // Use another pool for `withtyped` while adopting the new model, @@ -60,15 +63,7 @@ class EnvSet { #queryClient: Optional>; #oidc: Optional>>; - constructor(public readonly databaseUrl = EnvSet.envValues.dbUrl) {} - - get values() { - return EnvSet.envValues; - } - - get isTest() { - return EnvSet.envValues.isTest; - } + constructor(public readonly databaseUrl = EnvSet.values.dbUrl) {} get pool() { if (!this.#pool) { @@ -103,20 +98,16 @@ class EnvSet { } async load() { - const pool = await createPool(this.databaseUrl, this.isTest); + const pool = await createPool(this.databaseUrl, EnvSet.isTest); this.#pool = pool; - this.#queryClient = createQueryClient(this.databaseUrl, this.isTest); + this.#queryClient = createQueryClient(this.databaseUrl, EnvSet.isTest); const { getOidcConfigs } = createLogtoConfigLibrary(pool); const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs()]); this.#oidc = await loadOidcValues( - appendPath(this.values.endpoint, '/oidc').toString(), + appendPath(EnvSet.values.endpoint, '/oidc').toString(), oidcConfigs ); } } - -const envSet = new EnvSet(); - -export default envSet; diff --git a/packages/core/src/env-set/oidc.ts b/packages/core/src/env-set/oidc.ts index 1fcf91c2a..0fed022b9 100644 --- a/packages/core/src/env-set/oidc.ts +++ b/packages/core/src/env-set/oidc.ts @@ -16,7 +16,6 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => { const privateJwks = await Promise.all(privateKeys.map(async (key) => exportJWK(key))); const publicJwks = await Promise.all(publicKeys.map(async (key) => exportJWK(key))); const localJWKSet = createLocalJWKSet({ keys: publicJwks }); - const refreshTokenReuseInterval = configs[LogtoOidcConfigKey.RefreshTokenReuseInterval]; // Use ES384 if it's an Elliptic Curve key, otherwise fall back to default // It's for backwards compatibility since we were using RSA keys before v1.0.0-beta.20 @@ -28,7 +27,6 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => { jwkSigningAlg, localJWKSet, issuer, - refreshTokenReuseInterval, defaultIdTokenTtl: 60 * 60, defaultRefreshTokenTtl: 14 * 24 * 60 * 60, }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f7030994..2b82e8bf1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,19 +3,19 @@ import dotenv from 'dotenv'; import { findUp } from 'find-up'; import Koa from 'koa'; +import { EnvSet } from './env-set/index.js'; import initI18n from './i18n/init.js'; +import { tenantPool } from './tenants/index.js'; dotenv.config({ path: await findUp('.env', {}) }); // Import after env has configured -const { default: envSet } = await import('./env-set/index.js'); -await envSet.load(); const { loadConnectorFactories } = await import('./utils/connectors/factories.js'); try { const app = new Koa({ - proxy: envSet.values.trustProxyHeader, + proxy: EnvSet.values.trustProxyHeader, }); await initI18n(); await loadConnectorFactories(); @@ -27,5 +27,5 @@ try { console.error('Error while initializing app:'); console.error(error); - await Promise.all([envSet.poolSafe?.end(), envSet.queryClientSafe?.end()]).catch(noop); + await tenantPool.endAll().catch(noop); } diff --git a/packages/core/src/libraries/__mocks__/logto-config.ts b/packages/core/src/libraries/__mocks__/logto-config.ts index 9000aee9c..d767ca8e3 100644 --- a/packages/core/src/libraries/__mocks__/logto-config.ts +++ b/packages/core/src/libraries/__mocks__/logto-config.ts @@ -18,7 +18,6 @@ const { privateKey } = generateKeyPairSync('rsa', { const getOidcConfigs = async (): Promise => ({ [LogtoOidcConfigKey.PrivateKeys]: [privateKey], [LogtoOidcConfigKey.CookieKeys]: ['LOGTOSEKRIT1'], - [LogtoOidcConfigKey.RefreshTokenReuseInterval]: 3, }); export const createLogtoConfigLibrary = () => ({ getOidcConfigs }); diff --git a/packages/core/src/libraries/hook.ts b/packages/core/src/libraries/hook.ts index 23e25bee3..a4d351343 100644 --- a/packages/core/src/libraries/hook.ts +++ b/packages/core/src/libraries/hook.ts @@ -29,6 +29,7 @@ export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => { const { applications: { findApplicationById }, logs: { insertLog }, + // TODO: @gao should we use the library function thus we can pass full userinfo to the payload? users: { findUserById }, } = queries; diff --git a/packages/core/src/libraries/session.ts b/packages/core/src/libraries/session.ts index 16452c44d..64366adba 100644 --- a/packages/core/src/libraries/session.ts +++ b/packages/core/src/libraries/session.ts @@ -3,7 +3,7 @@ import type { InteractionResults } from 'oidc-provider'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; -import { findUserById, updateUserById } from '#src/queries/user.js'; +import type Queries from '#src/tenants/Queries.js'; export const assignInteractionResults = async ( ctx: Context, @@ -32,7 +32,12 @@ export const assignInteractionResults = async ( ctx.body = { redirectTo }; }; -export const saveUserFirstConsentedAppId = async (userId: string, applicationId: string) => { +export const saveUserFirstConsentedAppId = async ( + queries: Queries, + userId: string, + applicationId: string +) => { + const { findUserById, updateUserById } = queries.users; const { applicationId: firstConsentedAppId } = await findUserById(userId); if (!firstConsentedAppId) { diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 6fe1f5503..219233494 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -1,5 +1,5 @@ import { buildIdGenerator } from '@logto/core-kit'; -import type { User, CreateUser, Scope } from '@logto/schemas'; +import type { User, CreateUser, Scope, UserWithRoleNames } from '@logto/schemas'; import { Users, UsersPasswordEncryptionMethod } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; @@ -8,7 +8,7 @@ import { argon2Verify } from 'hash-wasm'; import pRetry from 'p-retry'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; @@ -46,16 +46,39 @@ export const verifyUserPassword = async (user: Nullable, password: string) return user; }; +export type UserLibrary = ReturnType; + export const createUserLibrary = (queries: Queries) => { const { pool, - roles: { findRolesByRoleNames, insertRoles, findRoleByRoleName }, - users: { hasUser, hasUserWithEmail, hasUserWithId, hasUserWithPhone, findUsersByIds }, + roles: { findRolesByRoleNames, insertRoles, findRoleByRoleName, findRolesByRoleIds }, + users: { + hasUser, + hasUserWithEmail, + hasUserWithId, + hasUserWithPhone, + findUsersByIds, + findUserById, + }, usersRoles: { insertUsersRoles, findUsersRolesByRoleId, findUsersRolesByUserId }, rolesScopes: { findRolesScopesByRoleIds }, scopes: { findScopesByIdsAndResourceId }, } = queries; + // TODO: @sijie remove this if no need for `UserWithRoleNames` anymore + const findUserByIdWithRoles = async (id: string): Promise => { + const user = await findUserById(id); + const userRoles = await findUsersRolesByUserId(user.id); + + const roles = + userRoles.length > 0 ? await findRolesByRoleIds(userRoles.map(({ roleId }) => roleId)) : []; + + return { + ...user, + roleNames: roles.map(({ name }) => name), + }; + }; + const generateUserId = async (retries = 500) => pRetry( async () => { @@ -81,7 +104,7 @@ export const createUserLibrary = (queries: Queries) => { ...rest }: OmitAutoSetFields & { roleNames?: string[] }) => { const computedRoleNames = deduplicate( - (roleNames ?? []).concat(envSet.values.userDefaultRoleNames) + (roleNames ?? []).concat(EnvSet.values.userDefaultRoleNames) ); if (computedRoleNames.length > 0) { @@ -173,6 +196,7 @@ export const createUserLibrary = (queries: Queries) => { }; return { + findUserByIdWithRoles, generateUserId, insertUser, checkIdentifierCollision, diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index 44f027147..723661845 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -2,9 +2,11 @@ import { UserRole } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; +import Sinon from 'sinon'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { envSetForTest } from '#src/test-utils/env-set.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import type { WithAuthContext } from './koa-auth.js'; @@ -55,14 +57,15 @@ describe('koaAuth middleware', () => { }); it('should read DEVELOPMENT_USER_ID from env variable first if not production and not integration test', async () => { - const spy = jest - .spyOn(envSet, 'values', 'get') - .mockReturnValue({ ...envSet.values, developmentUserId: 'foo' }); + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, + developmentUserId: 'foo', + }); - await koaAuth()(ctx, next); + await koaAuth(envSetForTest)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); - spy.mockRestore(); + stub.restore(); }); it('should read `development-user-id` from headers if not production and not integration test', async () => { @@ -74,27 +77,27 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth()(mockCtx, next); + await koaAuth(envSetForTest)(mockCtx, next); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); }); it('should read DEVELOPMENT_USER_ID from env variable first if is in production and integration test', async () => { - const spy = jest.spyOn(envSet, 'values', 'get').mockReturnValue({ - ...envSet.values, + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, developmentUserId: 'foo', isProduction: true, isIntegrationTest: true, }); - await koaAuth()(ctx, next); + await koaAuth(envSetForTest)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'foo' }); - spy.mockRestore(); + stub.restore(); }); it('should read `development-user-id` from headers if is in production and integration test', async () => { - const spy = jest.spyOn(envSet, 'values', 'get').mockReturnValue({ - ...envSet.values, + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, isProduction: true, isIntegrationTest: true, }); @@ -107,10 +110,10 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth()(mockCtx, next); + await koaAuth(envSetForTest)(mockCtx, next); expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' }); - spy.mockRestore(); + stub.restore(); }); it('should set user auth with given sub returned from accessToken', async () => { @@ -120,12 +123,12 @@ describe('koaAuth middleware', () => { authorization: 'Bearer access_token', }, }; - await koaAuth()(ctx, next); + await koaAuth(envSetForTest)(ctx, next); expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' }); }); it('expect to throw if authorization header is missing', async () => { - await expect(koaAuth()(ctx, next)).rejects.toMatchError(authHeaderMissingError); + await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(authHeaderMissingError); }); it('expect to throw if authorization header token type not recognized ', async () => { @@ -136,7 +139,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError(tokenNotSupportedError); + await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(tokenNotSupportedError); }); it('expect to throw if jwt sub is missing', async () => { @@ -149,7 +152,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError(jwtSubMissingError); + await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError(jwtSubMissingError); }); it('expect to have `client` type per jwt verify result', async () => { @@ -162,7 +165,7 @@ describe('koaAuth middleware', () => { }, }; - await koaAuth()(ctx, next); + await koaAuth(envSetForTest)(ctx, next); expect(ctx.auth).toEqual({ type: 'app', id: 'bar' }); }); @@ -176,7 +179,9 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(UserRole.Admin)(ctx, next)).rejects.toMatchError(forbiddenError); + await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( + forbiddenError + ); }); it('expect to throw if jwt role_names does not include admin', async () => { @@ -191,7 +196,9 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth(UserRole.Admin)(ctx, next)).rejects.toMatchError(forbiddenError); + await expect(koaAuth(envSetForTest, UserRole.Admin)(ctx, next)).rejects.toMatchError( + forbiddenError + ); }); it('expect to throw unauthorized error if unknown error occurs', async () => { @@ -205,7 +212,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError( + await expect(koaAuth(envSetForTest)(ctx, next)).rejects.toMatchError( new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error')) ); }); diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index 692c7b699..a457107b4 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -7,7 +7,7 @@ import { jwtVerify } from 'jose'; import type { MiddlewareType, Request } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import assertThat from '#src/utils/assert-that.js'; @@ -46,10 +46,11 @@ type TokenInfo = { }; export const verifyBearerTokenFromRequest = async ( + envSet: EnvSet, request: Request, resourceIndicator: Optional ): Promise => { - const { isProduction, isIntegrationTest, developmentUserId } = envSet.values; + const { isProduction, isIntegrationTest, developmentUserId } = EnvSet.values; const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; if ((!isProduction || isIntegrationTest) && userId) { @@ -78,10 +79,12 @@ export const verifyBearerTokenFromRequest = async ( }; export default function koaAuth( + envSet: EnvSet, forRole?: UserRole ): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest( + envSet, ctx.request, managementResource.indicator ); diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index d8226bd91..b393e7a75 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -2,7 +2,7 @@ import type { RequestErrorBody } from '@logto/schemas'; import type { Middleware } from 'koa'; import { HttpError } from 'koa'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; export default function koaErrorHandler(): Middleware< @@ -14,7 +14,7 @@ export default function koaErrorHandler(): Middleware< try { await next(); } catch (error: unknown) { - if (!envSet.values.isProduction) { + if (!EnvSet.values.isProduction) { console.error(error); } @@ -31,7 +31,7 @@ export default function koaErrorHandler(): Middleware< } // Should log 500 errors in prod anyway - if (envSet.values.isProduction) { + if (EnvSet.values.isProduction) { console.error(error); } diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index c24480425..6fca32e11 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -5,7 +5,7 @@ import koaBody from 'koa-body'; import type { IMiddleware, IRouterParamContext } from 'koa-router'; import type { ZodType, ZodTypeDef } from 'zod'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import ServerError from '#src/errors/ServerError/index.js'; import assertThat from '#src/utils/assert-that.js'; @@ -119,7 +119,7 @@ export default function koaGuard< const result = response.safeParse(ctx.body); if (!result.success) { - if (!envSet.values.isProduction) { + if (!EnvSet.values.isProduction) { console.error('Invalid response:', result.error); } throw new ServerError(); diff --git a/packages/core/src/middleware/koa-root-proxy.ts b/packages/core/src/middleware/koa-root-proxy.ts index 1ee55fbce..f33fe4b39 100644 --- a/packages/core/src/middleware/koa-root-proxy.ts +++ b/packages/core/src/middleware/koa-root-proxy.ts @@ -1,7 +1,7 @@ import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '#src/env-set/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import { appendPath } from '#src/utils/url.js'; export default function koaRootProxy< @@ -9,7 +9,7 @@ export default function koaRootProxy< ContextT extends IRouterParamContext, ResponseBodyT >(): MiddlewareType { - const { endpoint } = envSet.values; + const { endpoint } = EnvSet.values; return async (ctx, next) => { const requestPath = ctx.request.path; diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts index b756bfbff..1981ff699 100644 --- a/packages/core/src/middleware/koa-spa-proxy.test.ts +++ b/packages/core/src/middleware/koa-spa-proxy.test.ts @@ -1,6 +1,7 @@ import { pickDefault, createMockUtils } from '@logto/shared/esm'; +import Sinon from 'sinon'; -import envSet, { MountedApps } from '#src/env-set/index.js'; +import { EnvSet, MountedApps } from '#src/env-set/index.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; const { jest } = import.meta; @@ -53,8 +54,8 @@ describe('koaSpaProxy middleware', () => { }); it('production env should overwrite the request path to root if no target ui file are detected', async () => { - const spy = jest.spyOn(envSet, 'values', 'get').mockReturnValue({ - ...envSet.values, + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, isProduction: true, }); @@ -66,12 +67,12 @@ describe('koaSpaProxy middleware', () => { expect(mockStaticMiddleware).toBeCalled(); expect(ctx.request.path).toEqual('/'); - spy.mockRestore(); + stub.restore(); }); it('production env should call the static middleware if path hit the ui file directory', async () => { - const spy = jest.spyOn(envSet, 'values', 'get').mockReturnValue({ - ...envSet.values, + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, isProduction: true, }); @@ -81,6 +82,6 @@ describe('koaSpaProxy middleware', () => { await koaSpaProxy()(ctx, next); expect(mockStaticMiddleware).toBeCalled(); - spy.mockRestore(); + stub.restore(); }); }); diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index 87e343631..f3cba6658 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -5,7 +5,7 @@ import type { MiddlewareType } from 'koa'; import proxy from 'koa-proxies'; import type { IRouterParamContext } from 'koa-router'; -import envSet, { MountedApps } from '#src/env-set/index.js'; +import { EnvSet, MountedApps } from '#src/env-set/index.js'; import serveStatic from '#src/middleware/koa-serve-static.js'; export default function koaSpaProxy( @@ -17,7 +17,7 @@ export default function koaSpaProxy(provider: Provider): MiddlewareType { - const { endpoint } = envSet.values; + const { endpoint } = EnvSet.values; return async (ctx, next) => { const requestPath = ctx.request.path; diff --git a/packages/core/src/middleware/koa-welcome-proxy.test.ts b/packages/core/src/middleware/koa-welcome-proxy.test.ts index 421995b11..602039f38 100644 --- a/packages/core/src/middleware/koa-welcome-proxy.test.ts +++ b/packages/core/src/middleware/koa-welcome-proxy.test.ts @@ -1,14 +1,13 @@ -import { pickDefault, createMockUtils } from '@logto/shared/esm'; +import { pickDefault } from '@logto/shared/esm'; -import envSet, { MountedApps } from '#src/env-set/index.js'; +import { EnvSet, MountedApps } from '#src/env-set/index.js'; +import { MockQueries } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; const { jest } = import.meta; -const { mockEsm } = createMockUtils(jest); -const { hasActiveUsers } = mockEsm('#src/queries/user.js', () => ({ - hasActiveUsers: jest.fn(), -})); +const hasActiveUsers = jest.fn(); +const queries = new MockQueries({ users: { hasActiveUsers } }); const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js')); @@ -21,26 +20,26 @@ describe('koaWelcomeProxy', () => { }); it('should redirect to admin console if has AdminUsers', async () => { - const { endpoint } = envSet.values; + const { endpoint } = EnvSet.values; hasActiveUsers.mockResolvedValue(true); const ctx = createContextWithRouteParameters({ url: `/${MountedApps.Welcome}`, }); - await koaWelcomeProxy()(ctx, next); + await koaWelcomeProxy(queries)(ctx, next); expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}`); expect(next).not.toBeCalled(); }); it('should redirect to welcome page if has no Users', async () => { - const { endpoint } = envSet.values; + const { endpoint } = EnvSet.values; hasActiveUsers.mockResolvedValue(false); const ctx = createContextWithRouteParameters({ url: `/${MountedApps.Welcome}`, }); - await koaWelcomeProxy()(ctx, next); + await koaWelcomeProxy(queries)(ctx, next); expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}/welcome`); expect(next).not.toBeCalled(); }); diff --git a/packages/core/src/middleware/koa-welcome-proxy.ts b/packages/core/src/middleware/koa-welcome-proxy.ts index b338eb2de..cb92b875d 100644 --- a/packages/core/src/middleware/koa-welcome-proxy.ts +++ b/packages/core/src/middleware/koa-welcome-proxy.ts @@ -1,16 +1,17 @@ import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; -import envSet from '#src/env-set/index.js'; -import { hasActiveUsers } from '#src/queries/user.js'; +import { EnvSet } from '#src/env-set/index.js'; +import type Queries from '#src/tenants/Queries.js'; import { appendPath } from '#src/utils/url.js'; export default function koaWelcomeProxy< StateT, ContextT extends IRouterParamContext, ResponseBodyT ->(): MiddlewareType { - const { adminConsoleUrl } = envSet.values; +>(queries: Queries): MiddlewareType { + const { hasActiveUsers } = queries.users; + const { adminConsoleUrl } = EnvSet.values; return async (ctx) => { if (await hasActiveUsers()) { diff --git a/packages/core/src/oidc/adapter.test.ts b/packages/core/src/oidc/adapter.test.ts index ac8fada44..79b2a29a1 100644 --- a/packages/core/src/oidc/adapter.test.ts +++ b/packages/core/src/oidc/adapter.test.ts @@ -3,6 +3,7 @@ import { createMockUtils } from '@logto/shared/esm'; import snakecaseKeys from 'snakecase-keys'; import { mockApplication } from '#src/__mocks__/index.js'; +import { envSetForTest } from '#src/test-utils/env-set.js'; import { MockQueries } from '#src/test-utils/tenant.js'; import { getConstantClientMetadata } from './utils.js'; @@ -47,7 +48,7 @@ const now = Date.now(); describe('postgres Adapter', () => { it('Client Modal', async () => { const rejectError = new Error('Not implemented'); - const adapter = postgresAdapter(queries, 'Client'); + const adapter = postgresAdapter(envSetForTest, queries, 'Client'); await expect(adapter.upsert('client', {}, 0)).rejects.toMatchError(rejectError); await expect(adapter.findByUserCode('foo')).rejects.toMatchError(rejectError); @@ -71,7 +72,7 @@ describe('postgres Adapter', () => { client_id, client_name, client_secret, - ...getConstantClientMetadata(type), + ...getConstantClientMetadata(envSetForTest, type), ...snakecaseKeys(oidcClientMetadata), ...customClientMetadata, }); @@ -84,7 +85,7 @@ describe('postgres Adapter', () => { const id = 'fooId'; const grantId = 'grantId'; const expireAt = 60; - const adapter = postgresAdapter(queries, modelName); + const adapter = postgresAdapter(envSetForTest, queries, modelName); await adapter.upsert(id, { uid, userCode }, expireAt); expect(upsertInstance).toBeCalledWith({ diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index 2eaa2127f..13e3ae5c5 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -7,21 +7,21 @@ import type { AdapterFactory, AllClientMetadata } from 'oidc-provider'; import { errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; -import envSet, { MountedApps } from '#src/env-set/index.js'; +import { EnvSet, MountedApps } from '#src/env-set/index.js'; import type Queries from '#src/tenants/Queries.js'; import { appendPath } from '#src/utils/url.js'; import { getConstantClientMetadata } from './utils.js'; -const buildAdminConsoleClientMetadata = (): AllClientMetadata => { - const { localhostUrl, adminConsoleUrl } = envSet.values; +const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => { + const { localhostUrl, adminConsoleUrl } = EnvSet.values; const urls = deduplicate([ appendPath(localhostUrl, '/console').toString(), adminConsoleUrl.toString(), ]); return { - ...getConstantClientMetadata(ApplicationType.SPA), + ...getConstantClientMetadata(envSet, ApplicationType.SPA), client_id: adminConsoleApplicationId, client_name: 'Admin Console', redirect_uris: urls.map((url) => appendPath(url, '/callback').toString()), @@ -32,7 +32,7 @@ const buildAdminConsoleClientMetadata = (): AllClientMetadata => { const buildDemoAppUris = ( oidcClientMetadata: OidcClientMetadata ): Pick => { - const { localhostUrl, endpoint } = envSet.values; + const { localhostUrl, endpoint } = EnvSet.values; const urls = [ appendPath(localhostUrl, MountedApps.DemoApp).toString(), appendPath(endpoint, MountedApps.DemoApp).toString(), @@ -47,6 +47,7 @@ const buildDemoAppUris = ( }; export default function postgresAdapter( + envSet: EnvSet, queries: Queries, modelName: string ): ReturnType { @@ -77,7 +78,7 @@ export default function postgresAdapter( client_id, client_secret, client_name, - ...getConstantClientMetadata(type), + ...getConstantClientMetadata(envSet, type), ...snakecaseKeys(oidcClientMetadata), ...(client_id === demoAppApplicationId && snakecaseKeys(buildDemoAppUris(oidcClientMetadata))), @@ -90,7 +91,7 @@ export default function postgresAdapter( find: async (id) => { // Directly return client metadata since Admin Console does not belong to any tenant in the OSS version. if (id === adminConsoleApplicationId) { - return buildAdminConsoleClientMetadata(); + return buildAdminConsoleClientMetadata(envSet); } return transpileClient( diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index c9f86870d..ac6f8f67e 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -1,9 +1,12 @@ -import { MockQueries } from '#src/test-utils/tenant.js'; +import { envSetForTest } from '#src/test-utils/env-set.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; import initOidc from './init.js'; describe('oidc provider init', () => { it('init should not throw', async () => { - expect(() => initOidc(new MockQueries())).not.toThrow(); + const { queries, libraries } = new MockTenant(); + + expect(() => initOidc(envSetForTest, queries, libraries)).not.toThrow(); }); }); diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 4ed523ac9..0f8b01064 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -8,13 +8,13 @@ import { tryThat } from '@logto/shared'; import Provider, { errors } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; -import envSet from '#src/env-set/index.js'; +import type { EnvSet } from '#src/env-set/index.js'; import { addOidcEventListeners } from '#src/event-listeners/index.js'; -import { createUserLibrary } from '#src/libraries/user.js'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; import postgresAdapter from '#src/oidc/adapter.js'; import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js'; import { routes } from '#src/routes/consts.js'; +import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; @@ -23,12 +23,7 @@ import { claimToUserKey, getUserClaims } from './scope.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(queries: Queries): Provider { - const { - applications: { findApplicationById }, - resources: { findResourceByIndicator }, - users: { findUserById }, - } = queries; +export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Libraries): Provider { const { issuer, cookieKeys, @@ -37,7 +32,11 @@ export default function initOidc(queries: Queries): Provider { defaultIdTokenTtl, defaultRefreshTokenTtl, } = envSet.oidc; - const { findUserScopesForResourceId } = createUserLibrary(queries); + const { + applications: { findApplicationById }, + resources: { findResourceByIndicator }, + } = queries; + const { findUserByIdWithRoles, findUserScopesForResourceId } = libraries.users; const logoutSource = readFileSync('static/html/logout.html', 'utf8'); const cookieConfig = Object.freeze({ @@ -47,7 +46,7 @@ export default function initOidc(queries: Queries): Provider { } as const); const oidc = new Provider(issuer, { - adapter: postgresAdapter.bind(null, queries), + adapter: postgresAdapter.bind(null, envSet, queries), renderError: (_ctx, _out, error) => { console.error(error); @@ -136,7 +135,7 @@ export default function initOidc(queries: Queries): Provider { claims: userClaims, // https://github.com/panva/node-oidc-provider/tree/main/docs#findaccount findAccount: async (_ctx, sub) => { - const user = await findUserById(sub); + const user = await findUserByIdWithRoles(sub); return { accountId: sub, @@ -192,7 +191,7 @@ export default function initOidc(queries: Queries): Provider { if (token.kind === 'AccessToken') { const { accountId } = token; const { roleNames } = await tryThat( - findUserById(accountId), + findUserByIdWithRoles(accountId), new errors.InvalidClient(`invalid user ${accountId}`) ); diff --git a/packages/core/src/oidc/utils.test.ts b/packages/core/src/oidc/utils.test.ts index 1e5f5c5a2..34fe68a8a 100644 --- a/packages/core/src/oidc/utils.test.ts +++ b/packages/core/src/oidc/utils.test.ts @@ -1,5 +1,7 @@ import { ApplicationType, CustomClientMetadataKey, GrantType } from '@logto/schemas'; +import { envSetForTest } from '#src/test-utils/env-set.js'; + import { isOriginAllowed, buildOidcClientMetadata, @@ -8,22 +10,22 @@ import { } from './utils.js'; describe('getConstantClientMetadata()', () => { - expect(getConstantClientMetadata(ApplicationType.SPA)).toEqual({ + expect(getConstantClientMetadata(envSetForTest, ApplicationType.SPA)).toEqual({ application_type: 'web', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'none', }); - expect(getConstantClientMetadata(ApplicationType.Native)).toEqual({ + expect(getConstantClientMetadata(envSetForTest, ApplicationType.Native)).toEqual({ application_type: 'native', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'none', }); - expect(getConstantClientMetadata(ApplicationType.Traditional)).toEqual({ + expect(getConstantClientMetadata(envSetForTest, ApplicationType.Traditional)).toEqual({ application_type: 'web', grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken], token_endpoint_auth_method: 'client_secret_basic', }); - expect(getConstantClientMetadata(ApplicationType.MachineToMachine)).toEqual({ + expect(getConstantClientMetadata(envSetForTest, ApplicationType.MachineToMachine)).toEqual({ application_type: 'web', grant_types: [GrantType.ClientCredentials], token_endpoint_auth_method: 'client_secret_basic', diff --git a/packages/core/src/oidc/utils.ts b/packages/core/src/oidc/utils.ts index 470f4077b..fea71f091 100644 --- a/packages/core/src/oidc/utils.ts +++ b/packages/core/src/oidc/utils.ts @@ -4,9 +4,12 @@ import { conditional } from '@silverhand/essentials'; import type { AllClientMetadata, ClientAuthMethod } from 'oidc-provider'; import { errors } from 'oidc-provider'; -import envSet from '#src/env-set/index.js'; +import type { EnvSet } from '#src/env-set/index.js'; -export const getConstantClientMetadata = (type: ApplicationType): AllClientMetadata => { +export const getConstantClientMetadata = ( + envSet: EnvSet, + type: ApplicationType +): AllClientMetadata => { const { jwkSigningAlg } = envSet.oidc; const getTokenEndpointAuthMethod = (): ClientAuthMethod => { diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index d9e8c459c..b08006f7d 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -12,21 +12,27 @@ import type { CommonQueryMethods, ValueExpression } from 'slonik'; import { sql } from 'slonik'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; -import envSet from '#src/env-set/index.js'; export type WithConsumed = T & { consumed?: boolean }; export type QueryResult = Pick; const { table, fields } = convertToIdentifiers(OidcModelInstances); +/** + * This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe; + * During the leeway window (in seconds), the consumed refresh token will be considered as valid. + * + * This is useful for distributed apps and serverless apps like Next.js, in which there is no shared memory. + */ +// Hard-code this value since 3 seconds is a reasonable number for concurrency and no need for further configuration +const refreshTokenReuseInterval = 3; + const isConsumed = (modelName: string, consumedAt: Nullable): boolean => { if (!consumedAt) { return false; } - const { refreshTokenReuseInterval } = envSet.oidc; - - if (modelName !== 'RefreshToken' || !refreshTokenReuseInterval) { + if (modelName !== 'RefreshToken') { return Boolean(consumedAt); } diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 05d4fb4ae..d28689381 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -8,7 +8,6 @@ import { sql } from 'slonik'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; -import envSet from '#src/env-set/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { Search } from '#src/utils/search.js'; import { buildConditionsFromSearch } from '#src/utils/search.js'; @@ -132,18 +131,3 @@ export const createRolesQueries = (pool: CommonQueryMethods) => { deleteRoleById, }; }; - -/** @deprecated Will be removed soon. Use createRolesQueries() factory instead. */ -export const { - countRoles, - findRoles, - findRolesByRoleIds, - findRolesByRoleNames, - findRoleByRoleName, - insertRoles, - insertRole, - findRoleById, - updateRole, - updateRoleById, - deleteRoleById, -} = createRolesQueries(envSet.pool); diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 82312c365..44b785458 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -1,4 +1,4 @@ -import type { User, CreateUser, UserWithRoleNames } from '@logto/schemas'; +import type { User, CreateUser } from '@logto/schemas'; import { SearchJointMode, Users } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; @@ -6,15 +6,10 @@ import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; -import envSet from '#src/env-set/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { Search } from '#src/utils/search.js'; import { buildConditionsFromSearch } from '#src/utils/search.js'; -// TODO: @sijie remove this -import { findRolesByRoleIds } from './roles.js'; -import { findUsersRolesByUserId } from './users-roles.js'; - const { table, fields } = convertToIdentifiers(Users); export const createUserQueries = (pool: CommonQueryMethods) => { @@ -39,22 +34,12 @@ export const createUserQueries = (pool: CommonQueryMethods) => { where ${fields.primaryPhone}=${phone} `); - const findUserById = async (id: string): Promise => { - const user = await pool.one(sql` + const findUserById = async (id: string): Promise => + pool.one(sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} where ${fields.id}=${id} `); - const userRoles = await findUsersRolesByUserId(user.id); - - const roles = - userRoles.length > 0 ? await findRolesByRoleIds(userRoles.map(({ roleId }) => roleId)) : []; - - return { - ...user, - roleNames: roles.map(({ name }) => name), - }; - }; const findUserByIdentity = async (target: string, userId: string) => pool.maybeOne( @@ -237,25 +222,3 @@ export const createUserQueries = (pool: CommonQueryMethods) => { getDailyNewUserCountsByTimeInterval, }; }; - -/** @deprecated Will be removed soon. Use createUserQueries() factory instead. */ -export const { - findUserByUsername, - findUserByEmail, - findUserByPhone, - findUserById, - findUserByIdentity, - hasUser, - hasUserWithId, - hasUserWithEmail, - hasUserWithPhone, - hasUserWithIdentity, - countUsers, - findUsers, - findUsersByIds, - updateUserById, - deleteUserById, - deleteUserIdentity, - hasActiveUsers, - getDailyNewUserCountsByTimeInterval, -} = createUserQueries(envSet.pool); diff --git a/packages/core/src/queries/users-roles.ts b/packages/core/src/queries/users-roles.ts index 6c92b5ad4..2caf5dbd5 100644 --- a/packages/core/src/queries/users-roles.ts +++ b/packages/core/src/queries/users-roles.ts @@ -4,7 +4,6 @@ import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; -import envSet from '#src/env-set/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(UsersRoles); @@ -72,13 +71,3 @@ export const createUsersRolesQueries = (pool: CommonQueryMethods) => { deleteUsersRolesByUserIdAndRoleId, }; }; - -/** @deprecated Will be removed soon. Use createUsersRolesQueries() factory instead. */ -export const { - countUsersRolesByRoleId, - findFirstUsersRolesByRoleIdAndUserIds, - findUsersRolesByUserId, - findUsersRolesByRoleId, - insertUsersRoles, - deleteUsersRolesByUserIdAndRoleId, -} = createUsersRolesQueries(envSet.pool); diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 2f43f761d..54e6f7505 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -92,6 +92,7 @@ const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js' })); const usersLibraries = { + findUserByIdWithRoles: jest.fn(async (id: string) => mockUser), generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( async (user: CreateUser): Promise => ({ @@ -100,6 +101,7 @@ const usersLibraries = { }) ), } satisfies Partial; +const { findUserByIdWithRoles } = usersLibraries; const adminUserRoutes = await pickDefault(import('./admin-user.js')); @@ -279,7 +281,7 @@ describe('adminUserRoutes', () => { const name = 'Michael'; const avatar = 'http://www.michael.png'; - findUserById.mockImplementationOnce(() => { + findUserByIdWithRoles.mockImplementationOnce(() => { throw new Error(' '); }); @@ -324,7 +326,7 @@ describe('adminUserRoutes', () => { await expect( userRequest.patch('/users/foo').send({ roleNames: ['superadmin'] }) ).resolves.toHaveProperty('status', 400); - expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserByIdWithRoles).toHaveBeenCalledTimes(1); expect(updateUserById).not.toHaveBeenCalled(); }); @@ -341,7 +343,7 @@ describe('adminUserRoutes', () => { const password = '123456'; const response = await userRequest.patch(`/users/${mockedUserId}/password`).send({ password }); expect(encryptUserPassword).toHaveBeenCalledWith(password); - expect(updateUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledTimes(1); expect(response.status).toEqual(200); expect(response.body).toEqual({ ...mockUserResponse, diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 0275c8868..0ae2910d3 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -34,7 +34,13 @@ export default function adminUserRoutes( usersRoles: { deleteUsersRolesByUserIdAndRoleId, findUsersRolesByRoleId, insertUsersRoles }, } = queries; const { - users: { checkIdentifierCollision, generateUserId, insertUser, findUsersByRoleName }, + users: { + checkIdentifierCollision, + generateUserId, + insertUser, + findUsersByRoleName, + findUserByIdWithRoles, + }, } = libraries; router.get('/users', koaPagination(), async (ctx, next) => { @@ -81,7 +87,7 @@ export default function adminUserRoutes( params: { userId }, } = ctx.guard; - const user = await findUserById(userId); + const user = await findUserByIdWithRoles(userId); ctx.body = pick(user, 'roleNames', ...userInfoSelectFields); @@ -204,7 +210,7 @@ export default function adminUserRoutes( body, } = ctx.guard; - const user = await findUserById(userId); + const user = await findUserByIdWithRoles(userId); await checkIdentifierCollision(body, userId); const { roleNames, ...userUpdates } = body; @@ -357,6 +363,8 @@ export default function adminUserRoutes( ctx.body = pick(updatedUser, ...userInfoSelectFields); return next(); + // TODO: @sijie break into smaller files + // eslint-disable-next-line max-lines } ); } diff --git a/packages/core/src/routes/authn.test.ts b/packages/core/src/routes/authn.test.ts index 51bbeeccf..c615132b0 100644 --- a/packages/core/src/routes/authn.test.ts +++ b/packages/core/src/routes/authn.test.ts @@ -1,7 +1,6 @@ import { pickDefault, createMockUtils } from '@logto/shared/esm'; import RequestError from '#src/errors/RequestError/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; const { mockEsmWithActual } = createMockUtils(jest); @@ -13,6 +12,7 @@ const { verifyBearerTokenFromRequest } = await mockEsmWithActual( }) ); +const { createRequester } = await import('#src/utils/test-utils.js'); const request = createRequester({ anonymousRoutes: await pickDefault(import('#src/routes/authn.js')), }); @@ -71,7 +71,7 @@ describe('authn route for Hasura', () => { describe('with failed verification', () => { beforeEach(() => { - verifyBearerTokenFromRequest.mockImplementation(async (_, resource) => { + verifyBearerTokenFromRequest.mockImplementation(async (_, __, resource) => { if (resource) { throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }); } diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index 922314707..f3396afab 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -12,7 +12,9 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js'; * This router will have a route `/authn` to authenticate tokens with a general manner. * For now, we only implement the API for Hasura authentication. */ -export default function authnRoutes(...[router]: RouterInitArgs) { +export default function authnRoutes( + ...[router, { envSet }]: RouterInitArgs +) { router.get( '/authn/hasura', koaGuard({ @@ -25,7 +27,7 @@ export default function authnRoutes(...[router]: Rout const verifyToken = async (expectedResource?: string) => { try { - return await verifyBearerTokenFromRequest(ctx.request, expectedResource); + return await verifyBearerTokenFromRequest(envSet, ctx.request, expectedResource); } catch { return { sub: undefined, diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index a0d6c2a71..dfea06096 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -31,7 +31,7 @@ const createRouters = (tenant: TenantContext) => { interactionRoutes(interactionRouter, tenant); const managementRouter: AuthedRouter = new Router(); - managementRouter.use(koaAuth(UserRole.Admin)); + managementRouter.use(koaAuth(tenant.envSet, UserRole.Admin)); applicationRoutes(managementRouter, tenant); settingRoutes(managementRouter, tenant); connectorRoutes(managementRouter, tenant); diff --git a/packages/core/src/routes/interaction/consent.test.ts b/packages/core/src/routes/interaction/consent.test.ts index e11bf26e3..1bff86fd0 100644 --- a/packages/core/src/routes/interaction/consent.test.ts +++ b/packages/core/src/routes/interaction/consent.test.ts @@ -2,8 +2,10 @@ import type { User } from '@logto/schemas'; import { createMockUtils } from '@logto/shared/esm'; import { mockUser } from '#src/__mocks__/index.js'; -import { GrantMock } from '#src/test-utils/oidc-provider.js'; -import { createMockTenantWithInteraction } from '#src/test-utils/tenant.js'; +import type Queries from '#src/tenants/Queries.js'; +import { createMockProvider, GrantMock } from '#src/test-utils/oidc-provider.js'; +import type { Partial2 } from '#src/test-utils/tenant.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; import { interactionPrefix } from './const.js'; @@ -28,10 +30,14 @@ class Grant extends GrantMock { } } -const { findUserById, updateUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({ +const userQueries = { findUserById: jest.fn(async (): Promise => mockUser), updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })), -})); +}; +const { findUserById, updateUserById } = userQueries; + +// @ts-expect-error +const queries: Partial2 = { users: userQueries }; const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({ assignInteractionResults: jest.fn(), @@ -53,9 +59,9 @@ describe('interaction -> consent', () => { it('with empty details and reusing old grant', async () => { const sessionRequest = createRequester({ anonymousRoutes: interactionRoutes, - tenantContext: createMockTenantWithInteraction( - jest.fn().mockResolvedValue(baseInteractionDetails), - Grant + tenantContext: new MockTenant( + createMockProvider(jest.fn().mockResolvedValue(baseInteractionDetails), Grant), + queries ), }); @@ -74,12 +80,15 @@ describe('interaction -> consent', () => { it('with empty details and creating new grant', async () => { const sessionRequest = createRequester({ anonymousRoutes: interactionRoutes, - tenantContext: createMockTenantWithInteraction( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - grantId: 'exists', - }), - Grant + tenantContext: new MockTenant( + createMockProvider( + jest.fn().mockResolvedValue({ + ...baseInteractionDetails, + grantId: 'exists', + }), + Grant + ), + queries ), }); @@ -99,16 +108,19 @@ describe('interaction -> consent', () => { it('should save application id when the user first consented', async () => { const sessionRequest = createRequester({ anonymousRoutes: interactionRoutes, - tenantContext: createMockTenantWithInteraction( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - prompt: { - name: 'consent', - details: {}, - reasons: ['consent_prompt', 'native_client_prompt'], - }, - }), - Grant + tenantContext: new MockTenant( + createMockProvider( + jest.fn().mockResolvedValue({ + ...baseInteractionDetails, + prompt: { + name: 'consent', + details: {}, + reasons: ['consent_prompt', 'native_client_prompt'], + }, + }), + Grant + ), + queries ), }); @@ -121,20 +133,23 @@ describe('interaction -> consent', () => { it('missingOIDCScope and missingResourceScopes', async () => { const sessionRequest = createRequester({ anonymousRoutes: interactionRoutes, - tenantContext: createMockTenantWithInteraction( - jest.fn().mockResolvedValue({ - ...baseInteractionDetails, - prompt: { - details: { - missingOIDCScope: ['scope1', 'scope2'], - missingResourceScopes: { - resource1: ['scope1', 'scope2'], - resource2: ['scope3'], + tenantContext: new MockTenant( + createMockProvider( + jest.fn().mockResolvedValue({ + ...baseInteractionDetails, + prompt: { + details: { + missingOIDCScope: ['scope1', 'scope2'], + missingResourceScopes: { + resource1: ['scope1', 'scope2'], + resource2: ['scope3'], + }, }, }, - }, - }), - Grant + }), + Grant + ), + queries ), }); diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts index 762afefb3..dbc7d10f5 100644 --- a/packages/core/src/routes/interaction/consent.ts +++ b/packages/core/src/routes/interaction/consent.ts @@ -1,12 +1,11 @@ import { adminConsoleApplicationId, UserRole } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import type Router from 'koa-router'; -import type Provider from 'oidc-provider'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js'; -import { findUserById } from '#src/queries/user.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { interactionPrefix } from './const.js'; @@ -14,7 +13,7 @@ import type { WithInteractionDetailsContext } from './middleware/koa-interaction export default function consentRoutes( router: Router>, - provider: Provider + { provider, libraries, queries }: TenantContext ) { router.post(`${interactionPrefix}/consent`, async (ctx, next) => { const { interactionDetails } = ctx; @@ -32,7 +31,7 @@ export default function consentRoutes( // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console if (String(client_id) === adminConsoleApplicationId) { - const { roleNames } = await findUserById(accountId); + const { roleNames } = await libraries.users.findUserByIdWithRoles(accountId); assertThat( roleNames.includes(UserRole.Admin), @@ -44,7 +43,7 @@ export default function consentRoutes( conditional(grantId && (await provider.Grant.find(grantId))) ?? new provider.Grant({ accountId, clientId: String(client_id) }); - await saveUserFirstConsentedAppId(accountId, String(client_id)); + await saveUserFirstConsentedAppId(queries, accountId, String(client_id)); // V2: fulfill missing claims / resources const PromptDetailsBody = z.object({ diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 663c475aa..b576f08ca 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -297,7 +297,7 @@ export default function interactionRoutes( const verifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction); if (event !== InteractionEvent.ForgotPassword) { - await validateMandatoryUserProfile(ctx, verifiedInteraction); + await validateMandatoryUserProfile(queries.users, ctx, verifiedInteraction); } await submitInteraction(verifiedInteraction, ctx, tenant, log); @@ -351,5 +351,5 @@ export default function interactionRoutes( } ); - consentRoutes(router, provider); + consentRoutes(router, tenant); } diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts index 11c8f4751..cac3d8286 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -4,6 +4,7 @@ import type Provider from 'oidc-provider'; import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { MockQueries } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; @@ -11,9 +12,8 @@ import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; const { jest } = import.meta; const { mockEsm, mockEsmWithActual } = createMockUtils(jest); -const { findUserById } = await mockEsmWithActual('#src/queries/user.js', () => ({ - findUserById: jest.fn(), -})); +const findUserById = jest.fn(); +const { users } = new MockQueries({ users: { findUserById } }); const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({ isUserPasswordSet: jest.fn(), @@ -37,7 +37,7 @@ describe('validateMandatoryUserProfile', () => { }; it('username and password missing but required', async () => { - await expect(validateMandatoryUserProfile(baseCtx, interaction)).rejects.toMatchError( + await expect(validateMandatoryUserProfile(users, baseCtx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.password, MissingProfile.username] } @@ -45,7 +45,7 @@ describe('validateMandatoryUserProfile', () => { ); await expect( - validateMandatoryUserProfile(baseCtx, { + validateMandatoryUserProfile(users, baseCtx, { ...interaction, profile: { username: 'username', @@ -61,12 +61,12 @@ describe('validateMandatoryUserProfile', () => { }); isUserPasswordSet.mockResolvedValueOnce(true); - await expect(validateMandatoryUserProfile(baseCtx, interaction)).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(users, baseCtx, interaction)).resolves.not.toThrow(); }); it('register user has social profile', async () => { await expect( - validateMandatoryUserProfile(baseCtx, { + validateMandatoryUserProfile(users, baseCtx, { event: InteractionEvent.Register, profile: { username: 'foo', @@ -85,7 +85,7 @@ describe('validateMandatoryUserProfile', () => { }, }; - await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.email] } @@ -106,7 +106,7 @@ describe('validateMandatoryUserProfile', () => { }, }; - await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(users, ctx, interaction)).resolves.not.toThrow(); }); it('phone missing but required', async () => { @@ -118,7 +118,7 @@ describe('validateMandatoryUserProfile', () => { }, }; - await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.phone] } @@ -139,7 +139,7 @@ describe('validateMandatoryUserProfile', () => { }, }; - await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(users, ctx, interaction)).resolves.not.toThrow(); }); it('email or Phone required', async () => { @@ -155,7 +155,7 @@ describe('validateMandatoryUserProfile', () => { }, }; - await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( + await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.emailOrPhone] } @@ -163,14 +163,14 @@ describe('validateMandatoryUserProfile', () => { ); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { ...interaction, profile: { email: 'email' }, }) ).resolves.not.toThrow(); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { ...interaction, profile: { phone: '123456' }, }) @@ -191,35 +191,35 @@ describe('validateMandatoryUserProfile', () => { }; await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { event: InteractionEvent.Register, profile: { password: 'password' }, }) ).rejects.toMatchError(new RequestError({ code: 'user.missing_profile', status: 422 })); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { event: InteractionEvent.Register, profile: { username: 'username' }, }) ).resolves.not.toThrow(); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { event: InteractionEvent.Register, profile: { email: 'email' }, }) ).resolves.not.toThrow(); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { event: InteractionEvent.Register, profile: { phone: '123456' }, }) ).resolves.not.toThrow(); await expect( - validateMandatoryUserProfile(ctx, { + validateMandatoryUserProfile(users, ctx, { event: InteractionEvent.Register, profile: { connectorId: 'logto' }, }) diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts index 9b6e616d6..1f317325b 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -4,7 +4,7 @@ import type { Nullable } from '@silverhand/essentials'; import type { Context } from 'koa'; import RequestError from '#src/errors/RequestError/index.js'; -import { findUserById } from '#src/queries/user.js'; +import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; import type { WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; @@ -84,13 +84,15 @@ const validateRegisterMandatoryUserProfile = (profile?: Profile) => { }; export default async function validateMandatoryUserProfile( + userQueries: Queries['users'], ctx: WithInteractionSieContext, interaction: IdentifierVerifiedInteractionResult ) { const { signUp } = ctx.signInExperience; const { event, accountId, profile } = interaction; - const user = event === InteractionEvent.Register ? null : await findUserById(accountId); + const user = + event === InteractionEvent.Register ? null : await userQueries.findUserById(accountId); const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile }); assertThat( diff --git a/packages/core/src/tenants/Tenant.test.ts b/packages/core/src/tenants/Tenant.test.ts index aa2b2e248..26470533e 100644 --- a/packages/core/src/tenants/Tenant.test.ts +++ b/packages/core/src/tenants/Tenant.test.ts @@ -29,8 +29,8 @@ mockEsmDefault('#src/oidc/init.js', () => () => createMockProvider()); const Tenant = await pickDefault(import('./Tenant.js')); describe('Tenant', () => { - it('should call middleware factories', () => { - const _ = new Tenant('foo'); + it('should call middleware factories', async () => { + await Tenant.create('foo'); for (const middleware of middlewareList) { expect(middleware).toBeCalled(); diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts index e629aa68e..bf12c3510 100644 --- a/packages/core/src/tenants/Tenant.ts +++ b/packages/core/src/tenants/Tenant.ts @@ -5,7 +5,7 @@ import koaLogger from 'koa-logger'; import mount from 'koa-mount'; import type Provider from 'oidc-provider'; -import envSet, { MountedApps } from '#src/env-set/index.js'; +import { EnvSet, MountedApps } from '#src/env-set/index.js'; import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js'; import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js'; import koaErrorHandler from '#src/middleware/koa-error-handler.js'; @@ -26,6 +26,13 @@ import Queries from './Queries.js'; import type TenantContext from './TenantContext.js'; export default class Tenant implements TenantContext { + static async create(id: string) { + const envSet = new EnvSet(); + await envSet.load(); + + return new Tenant(envSet, id); + } + public readonly provider: Provider; public readonly queries: Queries; public readonly libraries: Libraries; @@ -37,11 +44,12 @@ export default class Tenant implements TenantContext { return mount(this.app); } - constructor(public id: string) { + constructor(public readonly envSet: EnvSet, public readonly id: string) { const modelRouters = createModelRouters(envSet.queryClient); const queries = new Queries(envSet.pool); const libraries = new Libraries(queries, modelRouters); + this.envSet = envSet; this.modelRouters = modelRouters; this.queries = queries; this.libraries = libraries; @@ -49,7 +57,7 @@ export default class Tenant implements TenantContext { // Init app const app = new Koa(); - const provider = initOidc(queries); + const provider = initOidc(envSet, queries, libraries); app.use(mount('/oidc', provider.app)); app.use(koaLogger()); @@ -59,12 +67,12 @@ export default class Tenant implements TenantContext { app.use(koaConnectorErrorHandler()); app.use(koaI18next()); - const apisApp = initRouter({ provider, queries, libraries, modelRouters }); + const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet }); app.use(mount('/api', apisApp)); app.use(mount('/', koaRootProxy())); - app.use(mount('/' + MountedApps.Welcome, koaWelcomeProxy())); + app.use(mount('/' + MountedApps.Welcome, koaWelcomeProxy(queries))); app.use( mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console)) diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts index 0ddc9242c..b17851238 100644 --- a/packages/core/src/tenants/TenantContext.ts +++ b/packages/core/src/tenants/TenantContext.ts @@ -1,11 +1,13 @@ import type Provider from 'oidc-provider'; +import type { EnvSet } from '#src/env-set/index.js'; import type { ModelRouters } from '#src/model-routers/index.js'; import type Libraries from './Libraries.js'; import type Queries from './Queries.js'; export default abstract class TenantContext { + public abstract readonly envSet: EnvSet; public abstract readonly provider: Provider; public abstract readonly queries: Queries; public abstract readonly libraries: Libraries; diff --git a/packages/core/src/tenants/index.ts b/packages/core/src/tenants/index.ts index 271343603..4173dc4b8 100644 --- a/packages/core/src/tenants/index.ts +++ b/packages/core/src/tenants/index.ts @@ -5,18 +5,28 @@ import Tenant from './Tenant.js'; class TenantPool { protected cache = new LRUCache({ max: 500 }); - get(tenantId: string): Tenant { + async get(tenantId: string): Promise { const tenant = this.cache.get(tenantId); if (tenant) { return tenant; } - const newTenant = new Tenant(tenantId); + const newTenant = await Tenant.create(tenantId); this.cache.set(tenantId, newTenant); return newTenant; } + + async endAll(): Promise { + await Promise.all( + this.cache.dump().flatMap(([, tenant]) => { + const { poolSafe, queryClientSafe } = tenant.value.envSet; + + return [poolSafe?.end(), queryClientSafe?.end()]; + }) + ); + } } export const tenantPool = new TenantPool(); diff --git a/packages/core/src/test-utils/env-set.ts b/packages/core/src/test-utils/env-set.ts new file mode 100644 index 000000000..caa6358fa --- /dev/null +++ b/packages/core/src/test-utils/env-set.ts @@ -0,0 +1,6 @@ +import { EnvSet } from '#src/env-set/index.js'; + +/** FOR TEST PURPOSE ONLY, DON'T USE IN PROD. */ +export const envSetForTest = new EnvSet(); + +await envSetForTest.load(); diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts index 259b8105f..b24f4fcbb 100644 --- a/packages/core/src/test-utils/tenant.ts +++ b/packages/core/src/test-utils/tenant.ts @@ -5,6 +5,7 @@ import Libraries from '#src/tenants/Libraries.js'; import Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import { envSetForTest } from './env-set.js'; import type { GrantMock } from './oidc-provider.js'; import { createMockProvider } from './oidc-provider.js'; import { MockQueryClient } from './query-client.js'; @@ -44,6 +45,7 @@ export type DeepPartial = T extends object export type Partial2 = { [key in keyof T]?: Partial }; export class MockTenant implements TenantContext { + public envSet = envSetForTest; public queries: Queries; public libraries: Libraries; public modelRouters = createModelRouters(new MockQueryClient()); diff --git a/packages/schemas/src/types/logto-config.ts b/packages/schemas/src/types/logto-config.ts index 0ca795a3c..580345c01 100644 --- a/packages/schemas/src/types/logto-config.ts +++ b/packages/schemas/src/types/logto-config.ts @@ -25,13 +25,11 @@ export const alterationStateGuard: Readonly<{ export enum LogtoOidcConfigKey { PrivateKeys = 'oidc.privateKeys', CookieKeys = 'oidc.cookieKeys', - RefreshTokenReuseInterval = 'oidc.refreshTokenReuseInterval', } export type LogtoOidcConfigType = { [LogtoOidcConfigKey.PrivateKeys]: string[]; [LogtoOidcConfigKey.CookieKeys]: string[]; - [LogtoOidcConfigKey.RefreshTokenReuseInterval]: number; }; export const logtoOidcConfigGuard: Readonly<{ @@ -39,12 +37,6 @@ export const logtoOidcConfigGuard: Readonly<{ }> = Object.freeze({ [LogtoOidcConfigKey.PrivateKeys]: z.string().array(), [LogtoOidcConfigKey.CookieKeys]: z.string().array(), - /** - * This interval helps to avoid concurrency issues when exchanging the rotating refresh token multiple times within a given timeframe. - * During the leeway window (in seconds), the consumed refresh token will be considered as valid. - * This is useful for distributed apps and serverless apps like Next.js, in which there is no shared memory. - */ - [LogtoOidcConfigKey.RefreshTokenReuseInterval]: z.number().gte(3), }); // Summary