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

refactor: remove deprecated singleton instances

This commit is contained in:
Gao Sun 2023-01-11 16:41:53 +08:00
parent e1c11a4da6
commit f317a917c9
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
51 changed files with 335 additions and 319 deletions

View file

@ -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 };
},
};

View file

@ -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',

View file

@ -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<void> {
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

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -51,8 +51,11 @@ const loadEnvValues = () => {
});
};
class EnvSet {
static envValues: ReturnType<typeof loadEnvValues> = loadEnvValues();
export class EnvSet {
static values: ReturnType<typeof loadEnvValues> = loadEnvValues();
static get isTest() {
return this.values.isTest;
}
#pool: Optional<DatabasePool>;
// Use another pool for `withtyped` while adopting the new model,
@ -60,15 +63,7 @@ class EnvSet {
#queryClient: Optional<QueryClient<PostgreSql>>;
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
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;

View file

@ -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,
});

View file

@ -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);
}

View file

@ -18,7 +18,6 @@ const { privateKey } = generateKeyPairSync('rsa', {
const getOidcConfigs = async (): Promise<LogtoOidcConfigType> => ({
[LogtoOidcConfigKey.PrivateKeys]: [privateKey],
[LogtoOidcConfigKey.CookieKeys]: ['LOGTOSEKRIT1'],
[LogtoOidcConfigKey.RefreshTokenReuseInterval]: 3,
});
export const createLogtoConfigLibrary = () => ({ getOidcConfigs });

View file

@ -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;

View file

@ -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) {

View file

@ -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<User>, password: string)
return user;
};
export type UserLibrary = ReturnType<typeof createUserLibrary>;
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<UserWithRoleNames> => {
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<CreateUser> & { 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,

View file

@ -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'))
);
});

View file

@ -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<string>
): Promise<TokenInfo> => {
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<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
envSet: EnvSet,
forRole?: UserRole
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest(
envSet,
ctx.request,
managementResource.indicator
);

View file

@ -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<StateT, ContextT, BodyT>(): Middleware<
@ -14,7 +14,7 @@ export default function koaErrorHandler<StateT, ContextT, BodyT>(): 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<StateT, ContextT, BodyT>(): Middleware<
}
// Should log 500 errors in prod anyway
if (envSet.values.isProduction) {
if (EnvSet.values.isProduction) {
console.error(error);
}

View file

@ -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();

View file

@ -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<StateT, ContextT, ResponseBodyT> {
const { endpoint } = envSet.values;
const { endpoint } = EnvSet.values;
return async (ctx, next) => {
const requestPath = ctx.request.path;

View file

@ -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();
});
});

View file

@ -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<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
@ -17,7 +17,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
const distributionPath = path.join('..', packagePath, 'dist');
const spaProxy: Middleware = envSet.values.isProduction
const spaProxy: Middleware = EnvSet.values.isProduction
? serveStatic(distributionPath)
: proxy('*', {
target: `http://localhost:${port}`,
@ -45,7 +45,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
return next();
}
if (!envSet.values.isProduction) {
if (!EnvSet.values.isProduction) {
return spaProxy(ctx, next);
}

View file

@ -2,7 +2,7 @@ import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type Provider from 'oidc-provider';
import envSet from '#src/env-set/index.js';
import { EnvSet } from '#src/env-set/index.js';
import { appendPath } from '#src/utils/url.js';
// Need To Align With UI
@ -20,7 +20,7 @@ export default function koaSpaSessionGuard<
ContextT extends IRouterParamContext,
ResponseBodyT
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { endpoint } = envSet.values;
const { endpoint } = EnvSet.values;
return async (ctx, next) => {
const requestPath = ctx.request.path;

View file

@ -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();
});

View file

@ -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<StateT, ContextT, ResponseBodyT> {
const { adminConsoleUrl } = envSet.values;
>(queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { hasActiveUsers } = queries.users;
const { adminConsoleUrl } = EnvSet.values;
return async (ctx) => {
if (await hasActiveUsers()) {

View file

@ -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({

View file

@ -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<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
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<AdapterFactory> {
@ -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(

View file

@ -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();
});
});

View file

@ -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}`)
);

View file

@ -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',

View file

@ -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 => {

View file

@ -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> = T & { consumed?: boolean };
export type QueryResult = Pick<OidcModelInstance, 'payload' | 'consumedAt'>;
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<number>): boolean => {
if (!consumedAt) {
return false;
}
const { refreshTokenReuseInterval } = envSet.oidc;
if (modelName !== 'RefreshToken' || !refreshTokenReuseInterval) {
if (modelName !== 'RefreshToken') {
return Boolean(consumedAt);
}

View file

@ -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);

View file

@ -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<UserWithRoleNames> => {
const user = await pool.one<User>(sql`
const findUserById = async (id: string): Promise<User> =>
pool.one<User>(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<User>(
@ -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);

View file

@ -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);

View file

@ -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<User> => ({
@ -100,6 +101,7 @@ const usersLibraries = {
})
),
} satisfies Partial<Libraries['users']>;
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,

View file

@ -34,7 +34,13 @@ export default function adminUserRoutes<T extends AuthedRouter>(
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<T extends AuthedRouter>(
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<T extends AuthedRouter>(
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<T extends AuthedRouter>(
ctx.body = pick(updatedUser, ...userInfoSelectFields);
return next();
// TODO: @sijie break into smaller files
// eslint-disable-next-line max-lines
}
);
}

View file

@ -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 });
}

View file

@ -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<T extends AnonymousRouter>(...[router]: RouterInitArgs<T>) {
export default function authnRoutes<T extends AnonymousRouter>(
...[router, { envSet }]: RouterInitArgs<T>
) {
router.get(
'/authn/hasura',
koaGuard({
@ -25,7 +27,7 @@ export default function authnRoutes<T extends AnonymousRouter>(...[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,

View file

@ -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);

View file

@ -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<User> => mockUser),
updateUserById: jest.fn(async (..._args: unknown[]) => ({ id: 'id' })),
}));
};
const { findUserById, updateUserById } = userQueries;
// @ts-expect-error
const queries: Partial2<Queries> = { 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
),
});

View file

@ -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<T>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
provider: Provider
{ provider, libraries, queries }: TenantContext
) {
router.post(`${interactionPrefix}/consent`, async (ctx, next) => {
const { interactionDetails } = ctx;
@ -32,7 +31,7 @@ export default function consentRoutes<T>(
// 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<T>(
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({

View file

@ -297,7 +297,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
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<T extends AnonymousRouter>(
}
);
consentRoutes(router, provider);
consentRoutes(router, tenant);
}

View file

@ -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' },
})

View file

@ -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<Context>,
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(

View file

@ -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();

View file

@ -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))

View file

@ -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;

View file

@ -5,18 +5,28 @@ import Tenant from './Tenant.js';
class TenantPool {
protected cache = new LRUCache<string, Tenant>({ max: 500 });
get(tenantId: string): Tenant {
async get(tenantId: string): Promise<Tenant> {
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<void> {
await Promise.all(
this.cache.dump().flatMap(([, tenant]) => {
const { poolSafe, queryClientSafe } = tenant.value.envSet;
return [poolSafe?.end(), queryClientSafe?.end()];
})
);
}
}
export const tenantPool = new TenantPool();

View file

@ -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();

View file

@ -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> = T extends object
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
export class MockTenant implements TenantContext {
public envSet = envSetForTest;
public queries: Queries;
public libraries: Libraries;
public modelRouters = createModelRouters(new MockQueryClient());

View file

@ -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