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:
parent
e1c11a4da6
commit
f317a917c9
51 changed files with 335 additions and 319 deletions
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'))
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`)
|
||||
);
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
})
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
6
packages/core/src/test-utils/env-set.ts
Normal file
6
packages/core/src/test-utils/env-set.ts
Normal 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();
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue