mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
Merge pull request #3264 from logto-io/gao-optimize-tenant-requests
refactor: optimize tenant requests
This commit is contained in:
commit
0d08762319
15 changed files with 167 additions and 57 deletions
|
@ -10,11 +10,12 @@ import './scss/overlayscrollbars.scss';
|
|||
|
||||
// eslint-disable-next-line import/no-unassigned-import
|
||||
import '@fontsource/roboto-mono';
|
||||
|
||||
import CloudApp from '@/cloud/App';
|
||||
import { cloudApi, getManagementApi, meApi } from '@/consts/resources';
|
||||
import initI18n from '@/i18n/init';
|
||||
|
||||
import { adminTenantEndpoint, getUserTenantId } from './consts';
|
||||
import { adminTenantEndpoint } from './consts';
|
||||
import { isCloud } from './consts/cloud';
|
||||
import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
|
||||
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
|
||||
|
@ -24,10 +25,12 @@ import Main from './pages/Main';
|
|||
void initI18n();
|
||||
|
||||
const Content = () => {
|
||||
const { tenants, isSettle } = useContext(TenantsContext);
|
||||
const currentTenantId = getUserTenantId();
|
||||
const { tenants, isSettle, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
const resources = deduplicate([
|
||||
// Explicitly add `currentTenantId` and deduplicate since the user may directly
|
||||
// access a URL with Tenant ID, adding the ID from the URL here can possibly remove one
|
||||
// additional redirect.
|
||||
...(currentTenantId && [getManagementApi(currentTenantId).indicator]),
|
||||
...(tenants ?? []).map(({ id }) => getManagementApi(id).indicator),
|
||||
...(isCloud ? [cloudApi.indicator] : []),
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import type { TenantInfo } from '@logto/schemas';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
|
||||
import Button from '@/components/Button';
|
||||
import DangerousRaw from '@/components/DangerousRaw';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -15,6 +16,7 @@ type Props = {
|
|||
|
||||
const Tenants = ({ data, onAdd }: Props) => {
|
||||
const api = useCloudApi();
|
||||
const { navigate } = useContext(TenantsContext);
|
||||
|
||||
const createTenant = useCallback(async () => {
|
||||
onAdd(await api.post('api/tenants').json<TenantInfo>());
|
||||
|
@ -26,18 +28,25 @@ const Tenants = ({ data, onAdd }: Props) => {
|
|||
}
|
||||
|
||||
if (data[0]) {
|
||||
window.location.assign('/' + data[0].id);
|
||||
navigate(data[0].id);
|
||||
} else {
|
||||
void createTenant();
|
||||
}
|
||||
}, [createTenant, data]);
|
||||
}, [createTenant, data, navigate]);
|
||||
|
||||
if (data.length > 1) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h3>Choose a tenant</h3>
|
||||
{data.map(({ id }) => (
|
||||
<a key={id} href={'/' + id}>
|
||||
<a
|
||||
key={id}
|
||||
href={'/' + id}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate(id);
|
||||
}}
|
||||
>
|
||||
<Button title={<DangerousRaw>{id}</DangerousRaw>} />
|
||||
</a>
|
||||
))}
|
||||
|
|
|
@ -5,7 +5,6 @@ import { useHref } from 'react-router-dom';
|
|||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { AppLoadingOffline } from '@/components/AppLoading/Offline';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
import Redirect from './Redirect';
|
||||
|
@ -13,7 +12,7 @@ import Tenants from './Tenants';
|
|||
|
||||
const Protected = () => {
|
||||
const api = useCloudApi();
|
||||
const { tenants, setTenants } = useContext(TenantsContext);
|
||||
const { tenants, setTenants, currentTenantId } = useContext(TenantsContext);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTenants = async () => {
|
||||
|
@ -27,8 +26,6 @@ const Protected = () => {
|
|||
}, [api, setTenants, tenants]);
|
||||
|
||||
if (tenants) {
|
||||
const currentTenantId = getUserTenantId();
|
||||
|
||||
if (currentTenantId) {
|
||||
return <Redirect tenants={tenants} toTenantId={currentTenantId} />;
|
||||
}
|
||||
|
@ -48,7 +45,8 @@ const Protected = () => {
|
|||
|
||||
const Main = () => {
|
||||
const { isAuthenticated, isLoading, signIn } = useLogto();
|
||||
const href = useHref(getUserTenantId() + '/callback');
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const href = useHref(currentTenantId + '/callback');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
|
|
|
@ -10,6 +10,9 @@ import { getTheme } from '@/utils/theme';
|
|||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
/**
|
||||
* An fullscreen loading component fetches local stored theme without sending request.
|
||||
*/
|
||||
export const AppLoadingOffline = () => {
|
||||
const theme = getTheme(
|
||||
trySafe(() => z.nativeEnum(AppearanceMode).parse(localStorage.getItem(themeStorageKey))) ??
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import ky from 'ky';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useEffect, createContext, useState } from 'react';
|
||||
import { useContext, useMemo, useEffect, createContext, useState } from 'react';
|
||||
|
||||
import { adminTenantEndpoint, getUserTenantId } from '@/consts';
|
||||
import { adminTenantEndpoint } from '@/consts';
|
||||
|
||||
import { TenantsContext } from './TenantsProvider';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
|
@ -17,24 +19,23 @@ export const AppEndpointsContext = createContext<AppEndpoints>({});
|
|||
|
||||
const AppEndpointsProvider = ({ children }: Props) => {
|
||||
const [endpoints, setEndpoints] = useState<AppEndpoints>({});
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const memorizedContext = useMemo(() => endpoints, [endpoints]);
|
||||
|
||||
useEffect(() => {
|
||||
const getEndpoint = async () => {
|
||||
const tenantId = getUserTenantId();
|
||||
|
||||
if (!tenantId) {
|
||||
if (!currentTenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = await ky
|
||||
.get(new URL(`api/.well-known/endpoints/${tenantId}`, adminTenantEndpoint))
|
||||
.get(new URL(`api/.well-known/endpoints/${currentTenantId}`, adminTenantEndpoint))
|
||||
.json<{ user: string }>();
|
||||
setEndpoints({ userEndpoint: new URL(user) });
|
||||
};
|
||||
|
||||
void getEndpoint();
|
||||
}, []);
|
||||
}, [currentTenantId]);
|
||||
|
||||
return (
|
||||
<AppEndpointsContext.Provider value={memorizedContext}>{children}</AppEndpointsContext.Provider>
|
||||
|
|
|
@ -2,9 +2,10 @@ import type { TenantInfo } from '@logto/schemas';
|
|||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { conditional, noop } from '@silverhand/essentials';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, createContext, useState } from 'react';
|
||||
import { useCallback, useMemo, createContext, useState } from 'react';
|
||||
|
||||
import { isCloud } from '@/consts/cloud';
|
||||
import { getUserTenantId } from '@/consts/tenants';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
|
@ -15,6 +16,8 @@ export type Tenants = {
|
|||
isSettle: boolean;
|
||||
setTenants: (tenants: TenantInfo[]) => void;
|
||||
setIsSettle: (isSettle: boolean) => void;
|
||||
currentTenantId: string;
|
||||
navigate: (tenantId: string) => void;
|
||||
};
|
||||
|
||||
const { tenantId, indicator } = defaultManagementApi.resource;
|
||||
|
@ -25,14 +28,23 @@ export const TenantsContext = createContext<Tenants>({
|
|||
setTenants: noop,
|
||||
isSettle: false,
|
||||
setIsSettle: noop,
|
||||
currentTenantId: '',
|
||||
navigate: noop,
|
||||
});
|
||||
|
||||
const TenantsProvider = ({ children }: Props) => {
|
||||
const [tenants, setTenants] = useState(initialTenants);
|
||||
const [isSettle, setIsSettle] = useState(false);
|
||||
const [currentTenantId, setCurrentTenantId] = useState(getUserTenantId());
|
||||
|
||||
const navigate = useCallback((tenantId: string) => {
|
||||
window.history.pushState({}, '', '/' + tenantId);
|
||||
setCurrentTenantId(tenantId);
|
||||
}, []);
|
||||
|
||||
const memorizedContext = useMemo(
|
||||
() => ({ tenants, setTenants, isSettle, setIsSettle }),
|
||||
[isSettle, tenants]
|
||||
() => ({ tenants, setTenants, isSettle, setIsSettle, currentTenantId, navigate }),
|
||||
[currentTenantId, isSettle, navigate, tenants]
|
||||
);
|
||||
|
||||
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||
|
|
|
@ -5,8 +5,9 @@ import { useCallback, useContext, useMemo } from 'react';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getBasename, getManagementApi, getUserTenantId, requestTimeout } from '@/consts';
|
||||
import { getBasename, getManagementApi, requestTimeout } from '@/consts';
|
||||
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
import { useConfirmModal } from './use-confirm-modal';
|
||||
|
||||
|
@ -30,11 +31,16 @@ type StaticApiProps = {
|
|||
export const useStaticApi = ({
|
||||
prefixUrl,
|
||||
hideErrorToast,
|
||||
resourceIndicator = getManagementApi(getUserTenantId()).indicator,
|
||||
resourceIndicator: resourceInput,
|
||||
}: StaticApiProps) => {
|
||||
const { isAuthenticated, getAccessToken, signOut } = useLogto();
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { show } = useConfirmModal();
|
||||
const resourceIndicator = useMemo(
|
||||
() => resourceInput ?? getManagementApi(currentTenantId).indicator,
|
||||
[currentTenantId, resourceInput]
|
||||
);
|
||||
|
||||
const toastError = useCallback(
|
||||
async (response: Response) => {
|
||||
|
|
|
@ -14,10 +14,42 @@ export default class GlobalValues {
|
|||
public readonly httpsKey = process.env.HTTPS_KEY_PATH;
|
||||
public readonly isHttpsEnabled = Boolean(this.httpsCert && this.httpsKey);
|
||||
|
||||
/**
|
||||
* The UrlSet with no prefix for Logto core service. It serves requests to the OIDC Provider and Management APIs
|
||||
* from all tenants.
|
||||
*
|
||||
* Especially, a glob (`*`) is allowed for the hostname of its property `endpoint` to indicate if the domain-based multi-tenancy (DBMT)
|
||||
* is enabled which affects some critical behaviors of Logto.
|
||||
*
|
||||
* **When DBMT is enabled**
|
||||
*
|
||||
* - For non-admin tenants, tenant endpoint will be generated by replacing the glob in the `urlSet.endpoint`.
|
||||
* - For admin tenant, if `adminUrlSet` has no endpoint available, tenant endpoint will be generated by replacing the glob in the `urlSet.endpoint`.
|
||||
* - Admin Console will NOT be served under admin tenant since the cloud service will do.
|
||||
* - Incoming requests will use glob matching to parse the tenant ID from the request URL.
|
||||
*
|
||||
* **When DBMT is disabled**
|
||||
*
|
||||
* - For non-admin tenants, tenant endpoint will always be `urlSet.endpoint`.
|
||||
* - For admin tenant, tenant endpoint will always be `adminUrlSet.endpoint`.
|
||||
* - Admin Console will be served under admin tenant.
|
||||
* - Incoming requests will check whether the URL matches adminUrlSet.endpoint, which indicates the admin tenant ID. If there is no match, the default tenant ID will be used.
|
||||
*/
|
||||
public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001);
|
||||
/**
|
||||
* The UrlSet with prefix `ADMIN_` for Logto admin tenant. To completely disable it, set `ADMIN_DISABLE_LOCALHOST` to a truthy value and leave `ADMIN_ENDPOINT` unset.
|
||||
*
|
||||
* Should be disabled on the cloud.
|
||||
*
|
||||
* @see urlSet For mutual effects between these two sets.
|
||||
*/
|
||||
public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_');
|
||||
/**
|
||||
* The UrlSet with prefix `CLOUD_` for Logto cloud service. It affects Admin Console Redirect URIs and some CORS configuration.
|
||||
*/
|
||||
public readonly cloudUrlSet = new UrlSet(this.isHttpsEnabled, 3003, 'CLOUD_');
|
||||
|
||||
/** @see urlSet For detailed explanation. */
|
||||
public readonly isDomainBasedMultiTenancy = this.urlSet.endpoint.hostname.includes('*');
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
|
|
|
@ -1,11 +1,30 @@
|
|||
import { deduplicate, getEnv, trySafe, yes } from '@silverhand/essentials';
|
||||
|
||||
/**
|
||||
* A class to store a set of URLs which may include a localhost URL and/or a custom domain URL.
|
||||
*
|
||||
* It's useful for aggregating URLs for the same purpose, e.g. to serve the core service.
|
||||
*/
|
||||
export default class UrlSet {
|
||||
readonly #port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
|
||||
readonly #endpoint = getEnv(this.envPrefix + 'ENDPOINT');
|
||||
|
||||
public readonly isLocalhostDisabled = yes(getEnv(this.envPrefix + 'DISABLE_LOCALHOST'));
|
||||
|
||||
/**
|
||||
* Construct a new UrlSet instance by reading the following env variables:
|
||||
*
|
||||
* - `${envPrefix}PORT` for getting the port number to listen; fall back to `defaultPort` if not found.
|
||||
* - `${envPrefix}ENDPOINT` for the custom endpoint. The value keeps raw and does not affected by `isHttpEnabled` or `envPrefix`.
|
||||
* - `${envPrefix}DISABLE_LOCALHOST` for disabling (or removing) localhost in the UrlSet if it's truthy (`1`, `true`, `yes`).
|
||||
*
|
||||
* Note: The constructor will take the parameters and read all corresponding env variables instantly,
|
||||
* thus instance properties will NOT change afterwards.
|
||||
*
|
||||
* @param isHttpsEnabled Indicates if Node-based HTTPS is enabled. It ONLY affects localhost URL protocol.
|
||||
* @param defaultPort The port number to fall back if no env variable found for specifying the port to listen.
|
||||
* @param envPrefix The prefix to add for all env variables, i.e. `PORT`, `ENDPOINT`, and `DISABLE_LOCALHOST`.
|
||||
*/
|
||||
constructor(
|
||||
public readonly isHttpsEnabled: boolean,
|
||||
protected readonly defaultPort: number,
|
||||
|
|
|
@ -29,6 +29,7 @@ export enum AdminApps {
|
|||
}
|
||||
|
||||
export class EnvSet {
|
||||
/** The value set for global configurations. */
|
||||
static values = new GlobalValues();
|
||||
|
||||
static get isTest() {
|
||||
|
|
|
@ -27,7 +27,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
keys: JWK[];
|
||||
issuer: string[];
|
||||
}> => {
|
||||
const { isDomainBasedMultiTenancy, urlSet, adminUrlSet } = EnvSet.values;
|
||||
const { isDomainBasedMultiTenancy, adminUrlSet } = EnvSet.values;
|
||||
|
||||
if (!isDomainBasedMultiTenancy && adminUrlSet.deduplicated().length === 0) {
|
||||
return { keys: [], issuer: [] };
|
||||
|
|
29
packages/core/src/middleware/koa-body-etag.ts
Normal file
29
packages/core/src/middleware/koa-body-etag.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { Nullable } from '@silverhand/essentials';
|
||||
import etag from 'etag';
|
||||
import type { Method } from 'got';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
|
||||
/**
|
||||
* Create a middleware function that calculates the ETag value based on response body,
|
||||
* and set status code to `304` and body to `null` if needed.
|
||||
*
|
||||
* @param methods An array of methods to match
|
||||
* @default methods ['GET']
|
||||
*/
|
||||
export default function koaBodyEtag<StateT, ContextT, ResponseBodyT>(
|
||||
methods: Array<Uppercase<Method>> = ['GET']
|
||||
): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> {
|
||||
return async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (methods.includes(ctx.method as Uppercase<Method>)) {
|
||||
ctx.response.etag = etag(JSON.stringify(ctx.body));
|
||||
|
||||
if (ctx.fresh) {
|
||||
ctx.status = 304;
|
||||
ctx.body = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -18,24 +18,24 @@ import { getConstantClientMetadata } from './utils.js';
|
|||
* as Admin Console is attached to the admin tenant in OSS and its endpoints are dynamic (from env variable).
|
||||
*/
|
||||
const transpileMetadata = (clientId: string, data: AllClientMetadata): AllClientMetadata => {
|
||||
if (clientId !== adminConsoleApplicationId) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const { adminUrlSet, cloudUrlSet } = EnvSet.values;
|
||||
const urls = [
|
||||
...adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString()),
|
||||
...cloudUrlSet.deduplicated().map(String),
|
||||
];
|
||||
|
||||
if (clientId === adminConsoleApplicationId) {
|
||||
return {
|
||||
...data,
|
||||
redirect_uris: [
|
||||
...(data.redirect_uris ?? []),
|
||||
...urls.map((url) => appendPath(url, '/callback').toString()),
|
||||
],
|
||||
post_logout_redirect_uris: [...(data.post_logout_redirect_uris ?? []), ...urls],
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
redirect_uris: [
|
||||
...(data.redirect_uris ?? []),
|
||||
...urls.map((url) => appendPath(url, '/callback').toString()),
|
||||
],
|
||||
post_logout_redirect_uris: [...(data.post_logout_redirect_uris ?? []), ...urls],
|
||||
};
|
||||
};
|
||||
|
||||
const buildDemoAppClientMetadata = (envSet: EnvSet): AllClientMetadata => {
|
||||
|
|
|
@ -12,6 +12,7 @@ import snakecaseKeys from 'snakecase-keys';
|
|||
import type { EnvSet } from '#src/env-set/index.js';
|
||||
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
||||
import koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
||||
import postgresAdapter from '#src/oidc/adapter.js';
|
||||
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
|
||||
import { routes } from '#src/routes/consts.js';
|
||||
|
@ -234,6 +235,7 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
|
|||
|
||||
// Provide audit log context for event listeners
|
||||
oidc.use(koaAuditLog(queries));
|
||||
oidc.use(koaBodyEtag());
|
||||
|
||||
return oidc;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import etag from 'etag';
|
||||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
||||
|
||||
import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
|
@ -17,17 +17,21 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
} = libraries;
|
||||
|
||||
if (id === adminTenantId) {
|
||||
router.get('/.well-known/endpoints/:tenantId', async (ctx, next) => {
|
||||
if (!ctx.params.tenantId) {
|
||||
throw new RequestError('request.invalid_input');
|
||||
}
|
||||
router.get(
|
||||
'/.well-known/endpoints/:tenantId',
|
||||
async (ctx, next) => {
|
||||
if (!ctx.params.tenantId) {
|
||||
throw new RequestError('request.invalid_input');
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
user: getTenantEndpoint(ctx.params.tenantId, EnvSet.values),
|
||||
};
|
||||
ctx.body = {
|
||||
user: getTenantEndpoint(ctx.params.tenantId, EnvSet.values),
|
||||
};
|
||||
|
||||
return next();
|
||||
});
|
||||
return next();
|
||||
},
|
||||
koaBodyEtag()
|
||||
);
|
||||
}
|
||||
|
||||
router.get(
|
||||
|
@ -64,15 +68,6 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
|
||||
return next();
|
||||
},
|
||||
async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
ctx.response.etag = etag(JSON.stringify(ctx.body));
|
||||
|
||||
if (ctx.fresh) {
|
||||
ctx.status = 304;
|
||||
ctx.body = null;
|
||||
}
|
||||
}
|
||||
koaBodyEtag()
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue