0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

refactor!: adjust packages to adapt admin tenant

This commit is contained in:
Gao Sun 2023-02-10 19:57:25 +08:00
parent 2af6fd114a
commit dd5b3037a8
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
30 changed files with 342 additions and 132 deletions

View file

@ -10,6 +10,7 @@ import {
adminTenantId,
defaultManagementApi,
createManagementApiInAdminTenant,
createMeApiInAdminTenant,
} from '@logto/schemas';
import { Hooks, Tenants } from '@logto/schemas/models';
import type { DatabaseTransactionConnection } from 'slonik';
@ -120,6 +121,7 @@ export const seedTables = async (
await createTenant(connection, adminTenantId);
await seedOidcConfigs(connection, adminTenantId);
await seedAdminData(connection, createManagementApiInAdminTenant(defaultTenantId));
await seedAdminData(connection, createMeApiInAdminTenant());
await Promise.all([
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),

View file

@ -13,7 +13,7 @@ import './scss/overlayscrollbars.scss';
import '@fontsource/roboto-mono';
import AppLoading from '@/components/AppLoading';
import Toast from '@/components/Toast';
import { managementApi } from '@/consts/management-api';
import { managementApi, meApi } from '@/consts/management-api';
import AppBoundary from '@/containers/AppBoundary';
import AppLayout from '@/containers/AppLayout';
import ErrorBoundary from '@/containers/ErrorBoundary';
@ -47,7 +47,7 @@ import {
UserDetailsTabs,
} from './consts/page-tabs';
import AppContent from './containers/AppContent';
import AppEndpointProvider, { AppEndpointContext } from './containers/AppEndpointProvider';
import AppEndpointsProvider, { AppEndpointsContext } from './containers/AppEndpointsProvider';
import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions';
import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings';
import CloudPreview from './pages/CloudPreview';
@ -63,9 +63,9 @@ void initI18n();
const Main = () => {
const swrOptions = useSwrOptions();
const { endpoint } = useContext(AppEndpointContext);
const { app, console } = useContext(AppEndpointsContext);
if (!endpoint) {
if (!app || !console) {
return <AppLoading />;
}
@ -159,18 +159,18 @@ const Main = () => {
const App = () => (
<BrowserRouter basename={getBasename('console', '5002')}>
<AppEndpointProvider>
<AppEndpointsProvider>
<LogtoProvider
config={{
endpoint: window.location.origin,
appId: adminConsoleApplicationId,
resources: [managementApi.indicator],
resources: [managementApi.indicator, meApi.indicator],
scopes: [UserScope.Identities, UserScope.CustomData, managementApi.scopeAll],
}}
>
<Main />
</LogtoProvider>
</AppEndpointProvider>
</AppEndpointsProvider>
</BrowserRouter>
);

View file

@ -1,5 +1,5 @@
import type { ResourceResponse, Scope, ScopeResponse } from '@logto/schemas';
import { managementApiScopeAll } from '@logto/schemas';
import { PredefinedScope } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import type { ChangeEvent } from 'react';
@ -79,7 +79,7 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
}
const existingScopeIds = roleScopes?.map(({ id }) => id) ?? [];
const excludeScopeIds = new Set([...existingScopeIds, managementApiScopeAll]);
const excludeScopeIds = new Set([...existingScopeIds, PredefinedScope.All]);
return allResources
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.has(id)))

View file

@ -1,10 +1,16 @@
import {
adminTenantId,
defaultTenantId,
getManagementApiResourceIndicator,
managementApiScopeAll,
PredefinedScope,
} from '@logto/schemas';
export const managementApi = Object.freeze({
indicator: getManagementApiResourceIndicator(defaultTenantId),
scopeAll: managementApiScopeAll,
scopeAll: PredefinedScope.All,
});
export const meApi = Object.freeze({
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
scopeAll: PredefinedScope.All,
});

View file

@ -1,31 +0,0 @@
import ky from 'ky';
import type { ReactNode } from 'react';
import { useMemo, useEffect, createContext, useState } from 'react';
type Props = {
children: ReactNode;
};
export const AppEndpointContext = createContext<{ endpoint?: URL }>({});
const AppEndpointProvider = ({ children }: Props) => {
const [endpoint, setEndpoint] = useState<URL>();
const memorizedContext = useMemo(() => ({ endpoint }), [endpoint]);
useEffect(() => {
const getEndpoint = async () => {
const { app } = await ky
.get(new URL('api/.well-known/endpoints', window.location.origin))
.json<{ app: string }>();
setEndpoint(new URL(app));
};
void getEndpoint();
}, []);
return (
<AppEndpointContext.Provider value={memorizedContext}>{children}</AppEndpointContext.Provider>
);
};
export default AppEndpointProvider;

View file

@ -0,0 +1,38 @@
import ky from 'ky';
import type { ReactNode } from 'react';
import { useMemo, useEffect, createContext, useState } from 'react';
type Props = {
children: ReactNode;
};
export type AppEndpoints = {
app?: URL;
console?: URL;
};
export type AppEndpointKey = keyof AppEndpoints;
export const AppEndpointsContext = createContext<AppEndpoints>({});
const AppEndpointsProvider = ({ children }: Props) => {
const [endpoints, setEndpoints] = useState<AppEndpoints>({});
const memorizedContext = useMemo(() => endpoints, [endpoints]);
useEffect(() => {
const getEndpoint = async () => {
const { app, console } = await ky
.get(new URL('api/.well-known/endpoints', window.location.origin))
.json<{ app: string; console: string }>();
setEndpoints({ app: new URL(app), console: new URL(console) });
};
void getEndpoint();
}, []);
return (
<AppEndpointsContext.Provider value={memorizedContext}>{children}</AppEndpointsContext.Provider>
);
};
export default AppEndpointsProvider;

View file

@ -6,7 +6,8 @@ import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { managementApi, requestTimeout } from '@/consts';
import { AppEndpointContext } from '@/containers/AppEndpointProvider';
import type { AppEndpointKey } from '@/containers/AppEndpointsProvider';
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
export class RequestError extends Error {
status: number;
@ -20,11 +21,17 @@ export class RequestError extends Error {
}
type Props = {
endpointKey?: AppEndpointKey;
hideErrorToast?: boolean;
resourceIndicator?: string;
};
const useApi = ({ hideErrorToast }: Props = {}) => {
const { endpoint } = useContext(AppEndpointContext);
const useApi = ({
hideErrorToast,
endpointKey = 'app',
resourceIndicator = managementApi.indicator,
}: Props = {}) => {
const endpoints = useContext(AppEndpointsContext);
const { isAuthenticated, getAccessToken } = useLogto();
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
@ -45,7 +52,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
const api = useMemo(
() =>
ky.create({
prefixUrl: endpoint,
prefixUrl: endpoints[endpointKey],
timeout: requestTimeout,
hooks: {
beforeError: hideErrorToast
@ -60,7 +67,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
beforeRequest: [
async (request) => {
if (isAuthenticated) {
const accessToken = await getAccessToken(managementApi.indicator);
const accessToken = await getAccessToken(resourceIndicator);
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
request.headers.set('Accept-Language', i18n.language);
}
@ -68,7 +75,16 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
],
},
}),
[endpoint, hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language]
[
endpoints,
endpointKey,
hideErrorToast,
toastError,
isAuthenticated,
getAccessToken,
resourceIndicator,
i18n.language,
]
);
return api;

View file

@ -6,17 +6,17 @@ import type { BareFetcher } from 'swr';
import useApi, { RequestError } from './use-api';
type withTotalNumber<T> = Array<Awaited<T> | number>;
type WithTotalNumber<T> = Array<Awaited<T> | number>;
type useSwrFetcherHook = {
<T>(): BareFetcher<T>;
<T extends unknown[]>(): BareFetcher<withTotalNumber<T>>;
<T extends unknown[]>(): BareFetcher<WithTotalNumber<T>>;
};
const useSwrFetcher: useSwrFetcherHook = <T>() => {
const api = useApi({ hideErrorToast: true });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const fetcher = useCallback<BareFetcher<T | withTotalNumber<T>>>(
const fetcher = useCallback<BareFetcher<T | WithTotalNumber<T>>>(
async (resource, init) => {
try {
const response = await api.get(resource, init);

View file

@ -5,10 +5,11 @@ import type { Nullable, Optional } from '@silverhand/essentials';
import { t } from 'i18next';
import { useCallback, useEffect, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import type { BareFetcher } from 'swr';
import useSWR from 'swr';
import { z } from 'zod';
import { themeStorageKey } from '@/consts';
import { meApi, themeStorageKey } from '@/consts';
import type { RequestError } from './use-api';
import useApi from './use-api';
@ -35,10 +36,19 @@ const useUserPreferences = () => {
const { isAuthenticated, error: authError } = useLogto();
const userId = useLogtoUserId();
const shouldFetch = isAuthenticated && !authError && userId;
const { data, mutate, error } = useSWR<unknown, RequestError>(
shouldFetch && `api/users/${userId}/custom-data`
const api = useApi({ endpointKey: 'console', resourceIndicator: meApi.indicator });
const fetcher = useCallback<BareFetcher>(
async (resource, init) => {
const response = await api.get(resource, init);
return response.json();
},
[api]
);
const { data, mutate, error } = useSWR<unknown, RequestError>(
shouldFetch && `me/custom-data`,
fetcher
);
const api = useApi();
const parseData = useCallback((): UserPreferences => {
try {
@ -62,13 +72,11 @@ const useUserPreferences = () => {
}
const updated = await api
.patch(`api/users/${userId}/custom-data`, {
.patch(`me/custom-data`, {
json: {
customData: {
[key]: {
...userPreferences,
...data,
},
[key]: {
...userPreferences,
...data,
},
},
})

View file

@ -5,13 +5,14 @@ import { ConnectorType, AppearanceMode } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { format } from 'date-fns';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useEffect, useMemo, useState, useRef, useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import PhoneInfo from '@/assets/images/phone-info.svg';
import Select from '@/components/Select';
import TabNav, { TabNavItem } from '@/components/TabNav';
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
import type { RequestError } from '@/hooks/use-api';
import useUiLanguages from '@/hooks/use-ui-languages';
@ -29,9 +30,8 @@ const Preview = ({ signInExperience, className }: Props) => {
const [platform, setPlatform] = useState<'desktopWeb' | 'mobile' | 'mobileWeb'>('desktopWeb');
const { data: allConnectors } = useSWR<ConnectorResponse[], RequestError>('api/connectors');
const previewRef = useRef<HTMLIFrameElement>(null);
const { customPhrases } = useUiLanguages();
const { languages } = useUiLanguages();
const { customPhrases, languages } = useUiLanguages();
const { app: appEndpoint } = useContext(AppEndpointsContext);
const modeOptions = useMemo(() => {
const light = { value: AppearanceMode.LightMode, title: t('sign_in_exp.preview.light') };
@ -120,9 +120,9 @@ const Preview = ({ signInExperience, className }: Props) => {
previewRef.current?.contentWindow?.postMessage(
{ sender: 'ac_preview', config },
window.location.origin
appEndpoint?.origin ?? ''
);
}, [config, customPhrases]);
}, [appEndpoint?.origin, config, customPhrases]);
useEffect(() => {
postPreviewMessage();
@ -210,7 +210,7 @@ const Preview = ({ signInExperience, className }: Props) => {
ref={previewRef}
// Allow all sandbox rules
sandbox={undefined}
src="/sign-in?preview=true"
src={new URL('/sign-in?preview=true', appEndpoint).toString()}
tabIndex={-1}
title={t('sign_in_exp.preview.title')}
/>

View file

@ -37,12 +37,6 @@ export default class UrlSet {
}
public get endpoint() {
const value = this.#endpoint || this.localhostUrl;
if (this.isLocalhostDisabled && new URL(value).hostname === 'localhost') {
throw new Error(localhostDisabledMessage);
}
return value;
return this.#endpoint || this.localhostUrl;
}
}

View file

@ -1,4 +1,6 @@
import { adminTenantId } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { trySafe } from '@silverhand/essentials';
import type { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik';
@ -13,7 +15,8 @@ import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js';
import { throwNotLoadedError } from './throw-errors.js';
export enum MountedApps {
/** Apps (also paths) for user tenants. */
export enum UserApps {
Api = 'api',
Oidc = 'oidc',
Console = 'console',
@ -21,6 +24,26 @@ export enum MountedApps {
Welcome = 'welcome',
}
/** Apps (also paths) ONLY for the admin tenant. */
export enum AdminApps {
Me = 'me',
}
const getTenantEndpoint = (id: string) => {
const { urlSet, adminUrlSet, isDomainBasedMultiTenancy } = EnvSet.values;
const adminUrl = trySafe(() => adminUrlSet.endpoint);
if (adminUrl && id === adminTenantId) {
return adminUrl;
}
if (!isDomainBasedMultiTenancy) {
return urlSet.endpoint;
}
return urlSet.endpoint.replace('*', id);
};
export class EnvSet {
static values = new GlobalValues();
@ -43,7 +66,7 @@ export class EnvSet {
#queryClient: Optional<QueryClient<PostgreSql>>;
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
constructor(public readonly databaseUrl: string) {}
constructor(public readonly tenantId: string, public readonly databaseUrl: string) {}
get pool() {
if (!this.#pool) {
@ -86,9 +109,7 @@ export class EnvSet {
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
const oidcConfigs = await getOidcConfigs();
this.#oidc = await loadOidcValues(
appendPath(EnvSet.values.endpoint, '/oidc').toString(),
oidcConfigs
);
const endpoint = getTenantEndpoint(this.tenantId);
this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs);
}
}

View file

@ -24,6 +24,7 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => {
return Object.freeze({
cookieKeys,
privateJwks,
publicJwks,
jwkSigningAlg,
localJWKSet,
issuer,

View file

@ -1,14 +1,16 @@
import type { IncomingHttpHeaders } from 'http';
import { defaultManagementApi } from '@logto/schemas';
import { adminTenantId, defaultManagementApi } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { jwtVerify } from 'jose';
import type { JWK } from 'jose';
import { createLocalJWKSet, jwtVerify } from 'jose';
import type { MiddlewareType, Request } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { tenantPool } from '#src/tenants/index.js';
import assertThat from '#src/utils/assert-that.js';
export type Auth = {
@ -23,7 +25,7 @@ export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamC
const bearerTokenIdentifier = 'Bearer';
const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
assertThat(
authorization,
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
@ -48,7 +50,7 @@ type TokenInfo = {
export const verifyBearerTokenFromRequest = async (
envSet: EnvSet,
request: Request,
resourceIndicator: Optional<string>
audience: Optional<string>
): Promise<TokenInfo> => {
const { isProduction, isIntegrationTest, developmentUserId } = EnvSet.values;
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
@ -59,14 +61,35 @@ export const verifyBearerTokenFromRequest = async (
return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] };
}
const getKeysAndIssuer = async (): Promise<[JWK[], string[]]> => {
const { publicJwks, issuer } = envSet.oidc;
if (envSet.tenantId === adminTenantId) {
return [publicJwks, [issuer]];
}
const {
envSet: { oidc: adminOidc },
} = await tenantPool.get(adminTenantId);
return [
[...publicJwks, ...adminOidc.publicJwks],
[issuer, adminOidc.issuer],
];
};
try {
const { localJWKSet, issuer } = envSet.oidc;
const [keys, issuer] = await getKeysAndIssuer();
const {
payload: { sub, client_id: clientId, scope = '' },
} = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, {
issuer,
audience: resourceIndicator,
});
} = await jwtVerify(
extractBearerTokenFromHeaders(request.headers),
createLocalJWKSet({ keys }),
{
issuer,
audience,
}
);
assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));
@ -81,17 +104,19 @@ export const verifyBearerTokenFromRequest = async (
};
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
envSet: EnvSet
envSet: EnvSet,
audience: string,
expectScopes = [defaultManagementApi.scope.name]
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
envSet,
ctx.request,
defaultManagementApi.resource.indicator
audience
);
assertThat(
scopes.includes(defaultManagementApi.scope.name),
expectScopes.every((scope) => scopes.includes(scope)),
new RequestError({ code: 'auth.forbidden', status: 403 })
);

View file

@ -1,7 +1,7 @@
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import Sinon from 'sinon';
import { EnvSet, MountedApps } from '#src/env-set/index.js';
import { EnvSet, UserApps } from '#src/env-set/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
@ -10,6 +10,7 @@ const { mockEsmDefault } = createMockUtils(jest);
const mockProxyMiddleware = jest.fn();
const mockStaticMiddleware = jest.fn();
const mountedApps = Object.values(UserApps);
mockEsmDefault('fs/promises', () => ({
readdir: jest.fn().mockResolvedValue(['sign-in']),
@ -34,14 +35,14 @@ describe('koaSpaProxy middleware', () => {
const next = jest.fn();
for (const app of Object.values(MountedApps)) {
for (const app of Object.values(UserApps)) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
it(`${app} path should not call dev proxy`, async () => {
const ctx = createContextWithRouteParameters({
url: `/${app}/foo`,
});
await koaSpaProxy()(ctx, next);
await koaSpaProxy(mountedApps)(ctx, next);
expect(mockProxyMiddleware).not.toBeCalled();
});
@ -49,7 +50,7 @@ describe('koaSpaProxy middleware', () => {
it('dev env should call dev proxy for SPA paths', async () => {
const ctx = createContextWithRouteParameters();
await koaSpaProxy()(ctx, next);
await koaSpaProxy(mountedApps)(ctx, next);
expect(mockProxyMiddleware).toBeCalled();
});
@ -63,7 +64,7 @@ describe('koaSpaProxy middleware', () => {
url: '/foo',
});
await koaSpaProxy()(ctx, next);
await koaSpaProxy(mountedApps)(ctx, next);
expect(mockStaticMiddleware).toBeCalled();
expect(ctx.request.path).toEqual('/');
@ -80,7 +81,7 @@ describe('koaSpaProxy middleware', () => {
url: '/sign-in',
});
await koaSpaProxy()(ctx, next);
await koaSpaProxy(mountedApps)(ctx, next);
expect(mockStaticMiddleware).toBeCalled();
stub.restore();
});

View file

@ -5,10 +5,11 @@ 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 } from '#src/env-set/index.js';
import serveStatic from '#src/middleware/koa-serve-static.js';
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
mountedApps: string[],
packagePath = 'ui',
port = 5001,
prefix = ''
@ -40,7 +41,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
// Route has been handled by one of mounted apps
if (
!prefix &&
Object.values(MountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
Object.values(mountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
) {
return next();
}

View file

@ -1,7 +1,7 @@
import { createMockUtils } from '@logto/shared/esm';
import Provider from 'oidc-provider';
import { MountedApps } from '#src/env-set/index.js';
import { UserApps } from '#src/env-set/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
@ -34,7 +34,7 @@ describe('koaSpaSessionGuard', () => {
const next = jest.fn();
for (const app of Object.values(MountedApps)) {
for (const app of Object.values(UserApps)) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
it(`${app} path should not redirect`, async () => {
const ctx = createContextWithRouteParameters({

View file

@ -7,7 +7,7 @@ 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, UserApps } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import { appendPath } from '#src/utils/url.js';
@ -30,7 +30,7 @@ const buildDemoAppUris = (
oidcClientMetadata: OidcClientMetadata
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
const { urlSet } = EnvSet.values;
const urls = urlSet.deduplicated().map((url) => appendPath(url, MountedApps.DemoApp).toString());
const urls = urlSet.deduplicated().map((url) => appendPath(url, UserApps.DemoApp).toString());
const data = {
redirectUris: deduplicate([...urls, ...oidcClientMetadata.redirectUris]),

View file

@ -0,0 +1,76 @@
import {
adminTenantId,
arbitraryObjectGuard,
getManagementApiResourceIndicator,
PredefinedScope,
} from '@logto/schemas';
import Koa from 'koa';
import Router from 'koa-router';
import RequestError from '#src/errors/RequestError/index.js';
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
import koaAuth from '#src/middleware/koa-auth.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
export default function initMeApis(tenant: TenantContext): Koa {
if (tenant.id !== adminTenantId) {
throw new Error('`/me` routes should only be initialized in the admin tenant.');
}
const { findUserById, updateUserById } = tenant.queries.users;
const meRouter = new Router<unknown, WithAuthContext>();
console.log('????', getManagementApiResourceIndicator(adminTenantId, 'me'));
meRouter.use(
koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me'), [
PredefinedScope.All,
]),
async (ctx, next) => {
assertThat(
ctx.auth.type === 'user',
new RequestError({ code: 'auth.forbidden', status: 403 })
);
return next();
}
);
meRouter.get('/custom-data', async (ctx, next) => {
const { id: userId } = ctx.auth;
const user = await findUserById(userId);
ctx.body = user.customData;
return next();
});
meRouter.patch(
'/custom-data',
koaGuard({
body: arbitraryObjectGuard,
response: arbitraryObjectGuard,
}),
async (ctx, next) => {
const { id: userId } = ctx.auth;
const { body: customData } = ctx.guard;
await findUserById(userId);
const user = await updateUserById(userId, {
customData,
});
ctx.body = user.customData;
return next();
}
);
const meApp = new Koa();
meApp.use(meRouter.routes()).use(meRouter.allowedMethods());
return meApp;
}

View file

@ -19,7 +19,6 @@ export default function adminUserRoutes<T extends AuthedRouter>(
) {
const {
oidcModelInstances: { revokeInstanceByUserId },
roles: { findRolesByRoleNames },
users: {
deleteUserById,
deleteUserIdentity,

View file

@ -1,4 +1,5 @@
import cors from '@koa/cors';
import { getManagementApiResourceIndicator, PredefinedScope } from '@logto/schemas';
import Koa from 'koa';
import Router from 'koa-router';
@ -34,7 +35,9 @@ const createRouters = (tenant: TenantContext) => {
interactionRoutes(interactionRouter, tenant);
const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet));
managementRouter.use(
koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id), [PredefinedScope.All])
);
applicationRoutes(managementRouter, tenant);
logtoConfigRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant);
@ -62,7 +65,7 @@ const createRouters = (tenant: TenantContext) => {
return [interactionRouter, managementRouter, anonymousRouter];
};
export default function initRouter(tenant: TenantContext): Koa {
export default function initApis(tenant: TenantContext): Koa {
const apisApp = new Koa();
apisApp.use(

View file

@ -1,5 +1,6 @@
import type { User, Profile } from '@logto/schemas';
import {
UserRole,
getManagementApiAdminName,
defaultTenantId,
adminTenantId,
@ -173,7 +174,7 @@ export default async function submitInteraction(
id,
...upsertProfile,
},
createAdminUser ? [getManagementApiAdminName(defaultTenantId)] : []
createAdminUser ? [getManagementApiAdminName(defaultTenantId), UserRole.User] : []
);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });

View file

@ -2,7 +2,7 @@ import {
adminConsoleApplicationId,
defaultTenantId,
getManagementApiResourceIndicator,
managementApiScopeAll,
PredefinedScope,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router';
@ -42,7 +42,7 @@ export default function consentRoutes<T>(
);
assertThat(
scopes.some(({ name }) => name === managementApiScopeAll),
scopes.some(({ name }) => name === PredefinedScope.All),
new RequestError({ code: 'auth.forbidden', status: 401 })
);
}

View file

@ -1,3 +1,4 @@
import { adminTenantId } from '@logto/schemas';
import type { MiddlewareType } from 'koa';
import Koa from 'koa';
import compose from 'koa-compose';
@ -5,7 +6,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 { AdminApps, EnvSet, UserApps } 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 koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
@ -18,7 +19,8 @@ import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import { createModelRouters } from '#src/model-routers/index.js';
import initOidc from '#src/oidc/init.js';
import initRouter from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
import initApis from '#src/routes/init.js';
import Libraries from './Libraries.js';
import Queries from './Queries.js';
@ -28,7 +30,7 @@ import { getTenantDatabaseDsn } from './utils.js';
export default class Tenant implements TenantContext {
static async create(id: string): Promise<Tenant> {
// Treat the default database URL as the management URL
const envSet = new EnvSet(await getTenantDatabaseDsn(id));
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
await envSet.load();
return new Tenant(envSet, id);
@ -49,6 +51,11 @@ export default class Tenant implements TenantContext {
const modelRouters = createModelRouters(envSet.queryClient);
const queries = new Queries(envSet.pool);
const libraries = new Libraries(queries, modelRouters);
const isAdminTenant = id === adminTenantId;
const mountedApps = [
...Object.values(UserApps),
...(isAdminTenant ? Object.values(AdminApps) : []),
];
this.envSet = envSet;
this.modelRouters = modelRouters;
@ -68,29 +75,38 @@ export default class Tenant implements TenantContext {
app.use(koaConnectorErrorHandler());
app.use(koaI18next());
const tenantContext: TenantContext = { id, provider, queries, libraries, modelRouters, envSet };
// Mount APIs
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
app.use(mount('/api', apisApp));
app.use(mount('/api', initApis(tenantContext)));
// Mount `/me` APIs for admin tenant
if (id === adminTenantId) {
console.log('111111111111122221');
app.use(mount('/me', initMeApis(tenantContext)));
}
// Mount Admin Console
app.use(koaConsoleRedirectProxy(queries));
app.use(
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
mount(
'/' + UserApps.Console,
koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console)
)
);
// Mount demo app
app.use(
mount(
'/' + MountedApps.DemoApp,
'/' + UserApps.DemoApp,
compose([
koaCheckDemoApp(this.queries),
koaSpaProxy(MountedApps.DemoApp, 5003, MountedApps.DemoApp),
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp),
])
)
);
// Mount UI
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
this.app = app;
this.provider = provider;

View file

@ -7,6 +7,7 @@ import type Libraries from './Libraries.js';
import type Queries from './Queries.js';
export default abstract class TenantContext {
public abstract readonly id: string;
public abstract readonly envSet: EnvSet;
public abstract readonly provider: Provider;
public abstract readonly queries: Queries;

View file

@ -1,5 +1,5 @@
import { EnvSet } from '#src/env-set/index.js';
export const mockEnvSet = new EnvSet(EnvSet.values.dbUrl);
export const mockEnvSet = new EnvSet(EnvSet.values.endpoint, EnvSet.values.dbUrl);
await mockEnvSet.load();

View file

@ -45,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 id = 'mock_id';
public envSet = mockEnvSet;
public queries: Queries;
public libraries: Libraries;

View file

@ -1,7 +1,7 @@
import { generateStandardId } from '@logto/core-kit';
import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js';
import { UserRole } from '../types/index.js';
import { PredefinedScope, UserRole } from '../types/index.js';
import { adminTenantId, defaultTenantId } from './tenant.js';
export type AdminData = {
@ -10,8 +10,6 @@ export type AdminData = {
role: CreateRole;
};
export const managementApiScopeAll = 'all';
// Consider remove the dependency of IDs
const defaultResourceId = 'management-api';
const defaultScopeAllId = 'management-api-all';
@ -28,14 +26,14 @@ export const defaultManagementApi = Object.freeze({
*
* Admin Console requires the access token of this resource to be functional.
*/
indicator: 'https://logto.app/api',
indicator: `https://${defaultTenantId}.logto.app/api`,
name: 'Logto Management API',
},
scope: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: defaultScopeAllId,
name: managementApiScopeAll,
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
/** @deprecated You should not rely on this constant. Change to something else. */
resourceId: defaultResourceId,
@ -49,8 +47,8 @@ export const defaultManagementApi = Object.freeze({
},
}) satisfies AdminData;
export const getManagementApiResourceIndicator = (tenantId: string) =>
`https://${tenantId}.logto.app/api`;
export const getManagementApiResourceIndicator = (tenantId: string, path = 'api') =>
`https://${tenantId}.logto.app/${path}`;
export const getManagementApiAdminName = (tenantId: string) => `${tenantId}:${UserRole.Admin}`;
@ -68,7 +66,7 @@ export const createManagementApiInAdminTenant = (tenantId: string): AdminData =>
scope: {
tenantId: adminTenantId,
id: generateStandardId(),
name: managementApiScopeAll,
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
@ -80,3 +78,29 @@ export const createManagementApiInAdminTenant = (tenantId: string): AdminData =>
},
});
};
export const createMeApiInAdminTenant = (): AdminData => {
const resourceId = generateStandardId();
return Object.freeze({
resource: {
tenantId: adminTenantId,
id: resourceId,
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
name: `Logto Me API`,
},
scope: {
tenantId: adminTenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Me API, allows all permissions.',
resourceId,
},
role: {
tenantId: adminTenantId,
id: generateStandardId(),
name: UserRole.User,
description: 'Default role for admin tenant.',
},
});
};

View file

@ -24,4 +24,10 @@ export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean };
export enum UserRole {
Admin = 'admin',
/** Common user role in admin tenant. */
User = 'user',
}
export enum PredefinedScope {
All = 'all',
}

View file

@ -29,9 +29,10 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
document.body.classList.add(conditionalString(styles.preview));
const previewMessageHandler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
// TODO: @simeng: we can check allowed origins via `/.well-known/endpoints`
// if (event.origin !== window.location.origin) {
// return;
// }
if (event.data.sender === 'ac_preview') {
// #event.data should be guarded at the provider's side