From dd5b3037a8620978a49f9294fbce638708bbbc15 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Fri, 10 Feb 2023 19:57:25 +0800 Subject: [PATCH] refactor!: adjust packages to adapt admin tenant --- .../cli/src/commands/database/seed/tables.ts | 2 + packages/console/src/App.tsx | 14 ++-- .../components/SourceScopesBox/index.tsx | 4 +- packages/console/src/consts/management-api.ts | 10 ++- .../containers/AppEndpointProvider/index.tsx | 31 -------- .../containers/AppEndpointsProvider/index.tsx | 38 ++++++++++ packages/console/src/hooks/use-api.ts | 28 +++++-- packages/console/src/hooks/use-swr-fetcher.ts | 6 +- .../console/src/hooks/use-user-preferences.ts | 28 ++++--- .../components/Preview/index.tsx | 14 ++-- packages/core/src/env-set/UrlSet.ts | 8 +- packages/core/src/env-set/index.ts | 33 ++++++-- packages/core/src/env-set/oidc.ts | 1 + packages/core/src/middleware/koa-auth.ts | 49 +++++++++--- .../core/src/middleware/koa-spa-proxy.test.ts | 13 ++-- packages/core/src/middleware/koa-spa-proxy.ts | 5 +- .../middleware/koa-spa-session-guard.test.ts | 4 +- packages/core/src/oidc/adapter.ts | 4 +- packages/core/src/routes-me/init.ts | 76 +++++++++++++++++++ packages/core/src/routes/admin-user.ts | 1 - packages/core/src/routes/init.ts | 7 +- .../interaction/actions/submit-interaction.ts | 3 +- .../core/src/routes/interaction/consent.ts | 4 +- packages/core/src/tenants/Tenant.ts | 34 ++++++--- packages/core/src/tenants/TenantContext.ts | 1 + packages/core/src/test-utils/env-set.ts | 2 +- packages/core/src/test-utils/tenant.ts | 1 + packages/schemas/src/seeds/management-api.ts | 40 ++++++++-- packages/schemas/src/types/user.ts | 6 ++ packages/ui/src/hooks/use-preview.ts | 7 +- 30 files changed, 342 insertions(+), 132 deletions(-) delete mode 100644 packages/console/src/containers/AppEndpointProvider/index.tsx create mode 100644 packages/console/src/containers/AppEndpointsProvider/index.tsx create mode 100644 packages/core/src/routes-me/init.ts 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