diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts
index 285113faf..232cd1260 100644
--- a/packages/cli/src/commands/database/seed/tables.ts
+++ b/packages/cli/src/commands/database/seed/tables.ts
@@ -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')),
diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx
index 6c34ab5cd..b6243613c 100644
--- a/packages/console/src/App.tsx
+++ b/packages/console/src/App.tsx
@@ -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 ;
}
@@ -159,18 +159,18 @@ const Main = () => {
const App = () => (
-
+
-
+
);
diff --git a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
index 1172b0af5..74fd549b8 100644
--- a/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
+++ b/packages/console/src/components/RoleScopesTransfer/components/SourceScopesBox/index.tsx
@@ -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)))
diff --git a/packages/console/src/consts/management-api.ts b/packages/console/src/consts/management-api.ts
index 2313bd289..4bb637896 100644
--- a/packages/console/src/consts/management-api.ts
+++ b/packages/console/src/consts/management-api.ts
@@ -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,
});
diff --git a/packages/console/src/containers/AppEndpointProvider/index.tsx b/packages/console/src/containers/AppEndpointProvider/index.tsx
deleted file mode 100644
index 997aab98e..000000000
--- a/packages/console/src/containers/AppEndpointProvider/index.tsx
+++ /dev/null
@@ -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();
- 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 (
- {children}
- );
-};
-
-export default AppEndpointProvider;
diff --git a/packages/console/src/containers/AppEndpointsProvider/index.tsx b/packages/console/src/containers/AppEndpointsProvider/index.tsx
new file mode 100644
index 000000000..917d5f47b
--- /dev/null
+++ b/packages/console/src/containers/AppEndpointsProvider/index.tsx
@@ -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({});
+
+const AppEndpointsProvider = ({ children }: Props) => {
+ const [endpoints, setEndpoints] = useState({});
+ 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 (
+ {children}
+ );
+};
+
+export default AppEndpointsProvider;
diff --git a/packages/console/src/hooks/use-api.ts b/packages/console/src/hooks/use-api.ts
index 6624c6890..225bdf806 100644
--- a/packages/console/src/hooks/use-api.ts
+++ b/packages/console/src/hooks/use-api.ts
@@ -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;
diff --git a/packages/console/src/hooks/use-swr-fetcher.ts b/packages/console/src/hooks/use-swr-fetcher.ts
index 10f061a1a..58086bed8 100644
--- a/packages/console/src/hooks/use-swr-fetcher.ts
+++ b/packages/console/src/hooks/use-swr-fetcher.ts
@@ -6,17 +6,17 @@ import type { BareFetcher } from 'swr';
import useApi, { RequestError } from './use-api';
-type withTotalNumber = Array | number>;
+type WithTotalNumber = Array | number>;
type useSwrFetcherHook = {
(): BareFetcher;
- (): BareFetcher>;
+ (): BareFetcher>;
};
const useSwrFetcher: useSwrFetcherHook = () => {
const api = useApi({ hideErrorToast: true });
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
- const fetcher = useCallback>>(
+ const fetcher = useCallback>>(
async (resource, init) => {
try {
const response = await api.get(resource, init);
diff --git a/packages/console/src/hooks/use-user-preferences.ts b/packages/console/src/hooks/use-user-preferences.ts
index 6d8fc14d0..134576766 100644
--- a/packages/console/src/hooks/use-user-preferences.ts
+++ b/packages/console/src/hooks/use-user-preferences.ts
@@ -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(
- shouldFetch && `api/users/${userId}/custom-data`
+ const api = useApi({ endpointKey: 'console', resourceIndicator: meApi.indicator });
+ const fetcher = useCallback(
+ async (resource, init) => {
+ const response = await api.get(resource, init);
+
+ return response.json();
+ },
+ [api]
+ );
+ const { data, mutate, error } = useSWR(
+ 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,
},
},
})
diff --git a/packages/console/src/pages/SignInExperience/components/Preview/index.tsx b/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
index ee1320360..498931e9f 100644
--- a/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
+++ b/packages/console/src/pages/SignInExperience/components/Preview/index.tsx
@@ -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('api/connectors');
const previewRef = useRef(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')}
/>
diff --git a/packages/core/src/env-set/UrlSet.ts b/packages/core/src/env-set/UrlSet.ts
index e0be46fc6..cccd37fa2 100644
--- a/packages/core/src/env-set/UrlSet.ts
+++ b/packages/core/src/env-set/UrlSet.ts
@@ -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;
}
}
diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts
index 3092bf01d..01268578f 100644
--- a/packages/core/src/env-set/index.ts
+++ b/packages/core/src/env-set/index.ts
@@ -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>;
#oidc: Optional>>;
- 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);
}
}
diff --git a/packages/core/src/env-set/oidc.ts b/packages/core/src/env-set/oidc.ts
index 0fed022b9..7533d7f58 100644
--- a/packages/core/src/env-set/oidc.ts
+++ b/packages/core/src/env-set/oidc.ts
@@ -24,6 +24,7 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => {
return Object.freeze({
cookieKeys,
privateJwks,
+ publicJwks,
jwkSigningAlg,
localJWKSet,
issuer,
diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts
index 6457c6e93..d61423c85 100644
--- a/packages/core/src/middleware/koa-auth.ts
+++ b/packages/core/src/middleware/koa-auth.ts
@@ -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 {
+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
+ audience: Optional
): Promise => {
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(
- envSet: EnvSet
+ envSet: EnvSet,
+ audience: string,
+ expectScopes = [defaultManagementApi.scope.name]
): MiddlewareType, 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 })
);
diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts
index 1981ff699..800942fed 100644
--- a/packages/core/src/middleware/koa-spa-proxy.test.ts
+++ b/packages/core/src/middleware/koa-spa-proxy.test.ts
@@ -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();
});
diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts
index f3cba6658..c76b6c58d 100644
--- a/packages/core/src/middleware/koa-spa-proxy.ts
+++ b/packages/core/src/middleware/koa-spa-proxy.ts
@@ -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(
+ mountedApps: string[],
packagePath = 'ui',
port = 5001,
prefix = ''
@@ -40,7 +41,7 @@ export default function koaSpaProxy app !== prefix && requestPath.startsWith(`/${app}`))
+ Object.values(mountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
) {
return next();
}
diff --git a/packages/core/src/middleware/koa-spa-session-guard.test.ts b/packages/core/src/middleware/koa-spa-session-guard.test.ts
index a2cebe0b7..2070d4616 100644
--- a/packages/core/src/middleware/koa-spa-session-guard.test.ts
+++ b/packages/core/src/middleware/koa-spa-session-guard.test.ts
@@ -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({
diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts
index 9c0bc5541..5e308c20a 100644
--- a/packages/core/src/oidc/adapter.ts
+++ b/packages/core/src/oidc/adapter.ts
@@ -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 => {
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]),
diff --git a/packages/core/src/routes-me/init.ts b/packages/core/src/routes-me/init.ts
new file mode 100644
index 000000000..650adfbc3
--- /dev/null
+++ b/packages/core/src/routes-me/init.ts
@@ -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();
+
+ 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;
+}
diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts
index eb3daa3f7..18a118393 100644
--- a/packages/core/src/routes/admin-user.ts
+++ b/packages/core/src/routes/admin-user.ts
@@ -19,7 +19,6 @@ export default function adminUserRoutes(
) {
const {
oidcModelInstances: { revokeInstanceByUserId },
- roles: { findRolesByRoleNames },
users: {
deleteUserById,
deleteUserIdentity,
diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts
index 8d44ce548..cce59f553 100644
--- a/packages/core/src/routes/init.ts
+++ b/packages/core/src/routes/init.ts
@@ -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(
diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts
index 72eaa911b..6afe041af 100644
--- a/packages/core/src/routes/interaction/actions/submit-interaction.ts
+++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts
@@ -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 } });
diff --git a/packages/core/src/routes/interaction/consent.ts b/packages/core/src/routes/interaction/consent.ts
index ee14998c6..9a09bfb27 100644
--- a/packages/core/src/routes/interaction/consent.ts
+++ b/packages/core/src/routes/interaction/consent.ts
@@ -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(
);
assertThat(
- scopes.some(({ name }) => name === managementApiScopeAll),
+ scopes.some(({ name }) => name === PredefinedScope.All),
new RequestError({ code: 'auth.forbidden', status: 401 })
);
}
diff --git a/packages/core/src/tenants/Tenant.ts b/packages/core/src/tenants/Tenant.ts
index 1afde1df8..a96684716 100644
--- a/packages/core/src/tenants/Tenant.ts
+++ b/packages/core/src/tenants/Tenant.ts
@@ -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 {
// 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;
diff --git a/packages/core/src/tenants/TenantContext.ts b/packages/core/src/tenants/TenantContext.ts
index b17851238..cfcdd2f06 100644
--- a/packages/core/src/tenants/TenantContext.ts
+++ b/packages/core/src/tenants/TenantContext.ts
@@ -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;
diff --git a/packages/core/src/test-utils/env-set.ts b/packages/core/src/test-utils/env-set.ts
index b135710e5..4f0835d24 100644
--- a/packages/core/src/test-utils/env-set.ts
+++ b/packages/core/src/test-utils/env-set.ts
@@ -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();
diff --git a/packages/core/src/test-utils/tenant.ts b/packages/core/src/test-utils/tenant.ts
index 95fa0d7f7..cd9dd1e17 100644
--- a/packages/core/src/test-utils/tenant.ts
+++ b/packages/core/src/test-utils/tenant.ts
@@ -45,6 +45,7 @@ export type DeepPartial = T extends object
export type Partial2 = { [key in keyof T]?: Partial };
export class MockTenant implements TenantContext {
+ public id = 'mock_id';
public envSet = mockEnvSet;
public queries: Queries;
public libraries: Libraries;
diff --git a/packages/schemas/src/seeds/management-api.ts b/packages/schemas/src/seeds/management-api.ts
index 3f34dcfaf..114f0ae9d 100644
--- a/packages/schemas/src/seeds/management-api.ts
+++ b/packages/schemas/src/seeds/management-api.ts
@@ -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.',
+ },
+ });
+};
diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts
index a03e18944..4219e9bc0 100644
--- a/packages/schemas/src/types/user.ts
+++ b/packages/schemas/src/types/user.ts
@@ -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',
}
diff --git a/packages/ui/src/hooks/use-preview.ts b/packages/ui/src/hooks/use-preview.ts
index e7e922fdd..4110ebf7a 100644
--- a/packages/ui/src/hooks/use-preview.ts
+++ b/packages/ui/src/hooks/use-preview.ts
@@ -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