diff --git a/packages/cloud/package.json b/packages/cloud/package.json index b6737a80a..13e3fc031 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -22,12 +22,13 @@ "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "2.2.0", - "@withtyped/postgres": "^0.6.0", - "@withtyped/server": "^0.6.0", + "@withtyped/postgres": "^0.7.0", + "@withtyped/server": "^0.7.0", "chalk": "^5.0.0", "dotenv": "^16.0.0", "find-up": "^6.3.0", "http-proxy": "^1.18.1", + "jose": "^4.11.0", "mime-types": "^2.1.35", "zod": "^3.20.2" }, diff --git a/packages/cloud/src/env-set/index.ts b/packages/cloud/src/env-set/index.ts new file mode 100644 index 000000000..aab43c73d --- /dev/null +++ b/packages/cloud/src/env-set/index.ts @@ -0,0 +1,15 @@ +const getEnv = (key: string) => process.env[key]; + +class GlobalValues { + public readonly logtoEndpoint = new URL(getEnv('LOGTO_ENDPOINT') ?? 'http://localhost:3002'); + public readonly dbUrl = getEnv('DB_URL'); + public readonly isProduction = getEnv('NODE_ENV') === 'production'; +} + +export const EnvSet = { + global: new GlobalValues(), + + get isProduction() { + return this.global.isProduction; + }, +}; diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index fd90749c4..ae9cc0050 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,23 +1,34 @@ +import { cloudApiIndicator } from '@logto/schemas'; +import type { RequestContext } from '@withtyped/server'; import createServer, { compose, withRequest } from '@withtyped/server'; import dotenv from 'dotenv'; import { findUp } from 'find-up'; +import withAuth from './middleware/with-auth.js'; import withHttpProxy from './middleware/with-http-proxy.js'; +import withPathname from './middleware/with-pathname.js'; import withSpa from './middleware/with-spa.js'; dotenv.config({ path: await findUp('.env', {}) }); +const { EnvSet } = await import('./env-set/index.js'); const { default: router } = await import('./routes/index.js'); -const isProduction = process.env.NODE_ENV === 'production'; const ignorePathnames = ['/api']; const { listen } = createServer({ port: 3003, composer: compose(withRequest()) - .and(router.routes()) .and( - isProduction + withPathname( + '/api', + compose() + .and(withAuth({ endpoint: EnvSet.global.logtoEndpoint, audience: cloudApiIndicator })) + .and(router.routes()) + ) + ) + .and( + EnvSet.isProduction ? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames }) : withHttpProxy('/', { target: 'http://localhost:5002', diff --git a/packages/cloud/src/middleware/with-auth.ts b/packages/cloud/src/middleware/with-auth.ts new file mode 100644 index 000000000..22655267f --- /dev/null +++ b/packages/cloud/src/middleware/with-auth.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert'; +import type { IncomingHttpHeaders } from 'node:http'; +import path from 'node:path/posix'; + +import type { NextFunction, RequestContext } from '@withtyped/server'; +import { RequestError } from '@withtyped/server'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { z } from 'zod'; + +const bearerTokenIdentifier = 'Bearer'; + +export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => { + assert(authorization, new RequestError('Authorization header is missing.', 401)); + assert( + authorization.startsWith(bearerTokenIdentifier), + new RequestError( + `Authorization token type is not supported. Valid type: "${bearerTokenIdentifier}".`, + 401 + ) + ); + + return authorization.slice(bearerTokenIdentifier.length + 1); +}; + +export type WithAuthContext = Context & { + auth: { + id: string; + scopes: string[]; + }; +}; + +export type WithAuthConfig = { + /** The Logto admin tenant endpoint. */ + endpoint: URL; + /** The audience (i.e. Resource Indicator) to expect. */ + audience: string; + /** The scopes (i.e. permissions) to expect. */ + scopes?: string[]; +}; + +export default function withAuth({ + endpoint, + audience, + scopes: expectScopes = [], +}: WithAuthConfig) { + const getJwkSet = (async () => { + const fetched = await fetch( + new URL(path.join(endpoint.pathname, 'oidc/.well-known/openid-configuration'), endpoint) + ); + const { jwks_uri: jwksUri, issuer } = z + .object({ jwks_uri: z.string(), issuer: z.string() }) + .parse(await fetched.json()); + + return Object.freeze([createRemoteJWKSet(new URL(jwksUri)), issuer] as const); + })(); + + return async (context: InputContext, next: NextFunction>) => { + const [getKey, issuer] = await getJwkSet; + + try { + const { + payload: { sub, scope }, + } = await jwtVerify(extractBearerTokenFromHeaders(context.request.headers), getKey, { + issuer, + audience, + }); + + assert(sub, new RequestError('"sub" is missing in JWT.', 401)); + + const scopes = typeof scope === 'string' ? scope.split(' ') : []; + assert( + expectScopes.every((scope) => scopes.includes(scope)), + new RequestError('Forbidden. Please check your permissions.', 403) + ); + + await next({ ...context, auth: { id: sub, scopes } }); + + return; + } catch (error: unknown) { + if (error instanceof RequestError) { + throw error; + } + + throw new RequestError('Unauthorized.', 401); + } + }; +} diff --git a/packages/cloud/src/middleware/with-http-proxy.ts b/packages/cloud/src/middleware/with-http-proxy.ts index 82eab2ce4..596aaa456 100644 --- a/packages/cloud/src/middleware/with-http-proxy.ts +++ b/packages/cloud/src/middleware/with-http-proxy.ts @@ -8,9 +8,7 @@ import { matchPathname } from '#src/utils/url.js'; const { createProxy } = HttpProxy; export type WithHttpProxyOptions = ServerOptions & { - /** - * An array of pathname prefixes to ignore. - */ + /** An array of pathname prefixes to ignore. */ ignorePathnames?: string[]; }; @@ -36,9 +34,9 @@ export default function withHttpProxy( request: { url }, } = context; - const matched = matchPathname(pathname, url.pathname); + const matched = matchPathname(pathname, url.pathname, ignorePathnames); - if (!matched || ignorePathnames?.some((prefix) => matchPathname(prefix, url.pathname))) { + if (!matched) { return next(context); } diff --git a/packages/cloud/src/middleware/with-pathname.ts b/packages/cloud/src/middleware/with-pathname.ts new file mode 100644 index 000000000..f67746386 --- /dev/null +++ b/packages/cloud/src/middleware/with-pathname.ts @@ -0,0 +1,31 @@ +import type { + HttpContext, + MiddlewareFunction, + NextFunction, + RequestContext, +} from '@withtyped/server'; + +import { matchPathname } from '#src/utils/url.js'; + +/** + * Build a middleware function that conditionally runs the given middleware function based on pathname prefix. + * + * @param pathname The pathname prefix to match. + * @param run The middleware function to run with the prefix matches. + */ +export default function withPathname< + InputContext extends RequestContext, + OutputContext extends RequestContext +>(pathname: string, run: MiddlewareFunction) { + return async ( + context: InputContext, + next: NextFunction, + httpContext: HttpContext + ) => { + if (!matchPathname(pathname, context.request.url.pathname)) { + return next(context); + } + + return run(context, next, httpContext); + }; +} diff --git a/packages/cloud/src/middleware/with-spa.ts b/packages/cloud/src/middleware/with-spa.ts index 0968e6dae..e54b003b1 100644 --- a/packages/cloud/src/middleware/with-spa.ts +++ b/packages/cloud/src/middleware/with-spa.ts @@ -21,9 +21,7 @@ export type WithSpaConfig = { * @default '/' */ pathname?: string; - /** - * An array of pathname prefixes to ignore. - */ + /** An array of pathname prefixes to ignore. */ ignorePathnames?: string[]; /** * The path to file to serve when the given path cannot be found in the file system. @@ -47,9 +45,9 @@ export default function withSpa({ request: { url }, } = context; - const pathname = matchPathname(rootPathname, url.pathname); + const pathname = matchPathname(rootPathname, url.pathname, ignorePathnames); - if (!pathname || ignorePathnames?.some((prefix) => matchPathname(prefix, url.pathname))) { + if (!pathname) { return next(context); } diff --git a/packages/cloud/src/queries/index.ts b/packages/cloud/src/queries/index.ts index 80d15f9db..d9045460e 100644 --- a/packages/cloud/src/queries/index.ts +++ b/packages/cloud/src/queries/index.ts @@ -1,5 +1,6 @@ import { createQueryClient } from '@withtyped/postgres'; +import { EnvSet } from '#src/env-set/index.js'; import { parseDsn } from '#src/utils/postgres.js'; -export const client = createQueryClient(parseDsn(process.env.DB_URL)); +export const client = createQueryClient(parseDsn(EnvSet.global.dbUrl)); diff --git a/packages/cloud/src/routes/index.ts b/packages/cloud/src/routes/index.ts index 2d2269d8b..f25c0871a 100644 --- a/packages/cloud/src/routes/index.ts +++ b/packages/cloud/src/routes/index.ts @@ -1,7 +1,9 @@ import { createRouter } from '@withtyped/server'; +import type { WithAuthContext } from '#src/middleware/with-auth.js'; + import { tenants } from './tenants.js'; -const router = createRouter('/api').pack(tenants); +const router = createRouter('/api').pack(tenants); export default router; diff --git a/packages/cloud/src/routes/tenants.ts b/packages/cloud/src/routes/tenants.ts index 8a7d3b892..06d698e83 100644 --- a/packages/cloud/src/routes/tenants.ts +++ b/packages/cloud/src/routes/tenants.ts @@ -1,17 +1,18 @@ -import { Router } from '@withtyped/server'; +import { createRouter } from '@withtyped/server'; import { z } from 'zod'; +import type { WithAuthContext } from '#src/middleware/with-auth.js'; import { client } from '#src/queries/index.js'; import { createTenantsQueries } from '#src/queries/tenants.js'; import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js'; const { getManagementApiLikeIndicatorsForUser } = createTenantsQueries(client); -export const tenants = new Router('/tenants').get( +export const tenants = createRouter('/tenants').get( '/', { response: z.object({ id: z.string(), indicator: z.string() }).array() }, async (context, next) => { - const { rows } = await getManagementApiLikeIndicatorsForUser('some_user_id'); + const { rows } = await getManagementApiLikeIndicatorsForUser(context.auth.id); const tenants = rows .map(({ indicator }) => ({ diff --git a/packages/cloud/src/utils/url.ts b/packages/cloud/src/utils/url.ts index 9a54000e5..bd776962c 100644 --- a/packages/cloud/src/utils/url.ts +++ b/packages/cloud/src/utils/url.ts @@ -6,10 +6,18 @@ export const normalizePath = (pathLike: string) => { return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value; }; -export const matchPathname = (toMatch: string, pathname: string) => { +export const matchPathname = ( + toMatch: string, + pathname: string, + ignorePathnames: string[] = [] +) => { const toMatchPathname = normalizePath(toMatch); const normalized = normalizePath(pathname); + if (ignorePathnames.some((prefix) => matchPathname(prefix, pathname))) { + return false; + } + if (normalized === toMatchPathname) { return '/'; } diff --git a/packages/core/package.json b/packages/core/package.json index ee0b7b9ac..df9872ddc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,8 +35,8 @@ "@logto/schemas": "workspace:*", "@logto/shared": "workspace:*", "@silverhand/essentials": "2.2.0", - "@withtyped/postgres": "^0.6.0", - "@withtyped/server": "^0.6.0", + "@withtyped/postgres": "^0.7.0", + "@withtyped/server": "^0.7.0", "chalk": "^5.0.0", "clean-deep": "^3.4.0", "date-fns": "^2.29.3", diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 61d97360f..292f9f831 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -53,6 +53,6 @@ }, "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { - "@withtyped/server": "^0.6.0" + "@withtyped/server": "^0.7.0" } } diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 82c2d9ef2..d82aba5f3 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -84,7 +84,7 @@ "@logto/language-kit": "workspace:*", "@logto/phrases": "workspace:*", "@logto/phrases-ui": "workspace:*", - "@withtyped/server": "^0.6.0", + "@withtyped/server": "^0.7.0", "zod": "^3.20.2" } } diff --git a/packages/schemas/src/consts/cloud.ts b/packages/schemas/src/consts/cloud.ts new file mode 100644 index 000000000..a8cc30c30 --- /dev/null +++ b/packages/schemas/src/consts/cloud.ts @@ -0,0 +1 @@ +export const cloudApiIndicator = 'https://cloud.logto.io/api'; diff --git a/packages/schemas/src/consts/index.ts b/packages/schemas/src/consts/index.ts new file mode 100644 index 000000000..0a3d23633 --- /dev/null +++ b/packages/schemas/src/consts/index.ts @@ -0,0 +1 @@ +export * from './cloud.js'; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 3d4607c6a..cca97da1a 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -3,3 +3,4 @@ export * from './db-entries/index.js'; export * from './types/index.js'; export * from './api/index.js'; export * from './seeds/index.js'; +export * from './consts/index.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a0ddc5d1..3f37c05e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,13 +114,14 @@ importers: '@types/http-proxy': ^1.17.9 '@types/mime-types': ^2.1.1 '@types/node': ^18.11.18 - '@withtyped/postgres': ^0.6.0 - '@withtyped/server': ^0.6.0 + '@withtyped/postgres': ^0.7.0 + '@withtyped/server': ^0.7.0 chalk: ^5.0.0 dotenv: ^16.0.0 eslint: ^8.21.0 find-up: ^6.3.0 http-proxy: ^1.18.1 + jose: ^4.11.0 lint-staged: ^13.0.0 mime-types: ^2.1.35 nodemon: ^2.0.19 @@ -131,12 +132,13 @@ importers: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 2.2.0 - '@withtyped/postgres': 0.6.0_@withtyped+server@0.6.0 - '@withtyped/server': 0.6.0 + '@withtyped/postgres': 0.7.0_@withtyped+server@0.7.0 + '@withtyped/server': 0.7.0 chalk: 5.1.2 dotenv: 16.0.0 find-up: 6.3.0 http-proxy: 1.18.1 + jose: 4.11.1 mime-types: 2.1.35 zod: 3.20.2 devDependencies: @@ -329,8 +331,8 @@ importers: '@types/semver': ^7.3.12 '@types/sinon': ^10.0.13 '@types/supertest': ^2.0.11 - '@withtyped/postgres': ^0.6.0 - '@withtyped/server': ^0.6.0 + '@withtyped/postgres': ^0.7.0 + '@withtyped/server': ^0.7.0 chalk: ^5.0.0 clean-deep: ^3.4.0 copyfiles: ^2.4.1 @@ -389,8 +391,8 @@ importers: '@logto/schemas': link:../schemas '@logto/shared': link:../shared '@silverhand/essentials': 2.2.0 - '@withtyped/postgres': 0.6.0_@withtyped+server@0.6.0 - '@withtyped/server': 0.6.0 + '@withtyped/postgres': 0.7.0_@withtyped+server@0.7.0 + '@withtyped/server': 0.7.0 chalk: 5.1.2 clean-deep: 3.4.0 date-fns: 2.29.3 @@ -538,7 +540,7 @@ importers: '@types/jest': ^29.1.2 '@types/jest-environment-puppeteer': ^5.0.2 '@types/node': ^18.11.18 - '@withtyped/server': ^0.6.0 + '@withtyped/server': ^0.7.0 dotenv: ^16.0.0 eslint: ^8.34.0 got: ^12.5.3 @@ -552,7 +554,7 @@ importers: text-encoder: ^0.0.4 typescript: ^4.9.4 dependencies: - '@withtyped/server': 0.6.0 + '@withtyped/server': 0.7.0 devDependencies: '@jest/types': 29.1.2 '@logto/connector-kit': link:../toolkit/connector-kit @@ -645,7 +647,7 @@ importers: '@types/jest': ^29.1.2 '@types/node': ^18.11.18 '@types/pluralize': ^0.0.29 - '@withtyped/server': ^0.6.0 + '@withtyped/server': ^0.7.0 camelcase: ^7.0.0 eslint: ^8.34.0 jest: ^29.1.2 @@ -663,7 +665,7 @@ importers: '@logto/language-kit': link:../toolkit/language-kit '@logto/phrases': link:../phrases '@logto/phrases-ui': link:../phrases-ui - '@withtyped/server': 0.6.0 + '@withtyped/server': 0.7.0 zod: 3.20.2 devDependencies: '@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy @@ -4569,21 +4571,21 @@ packages: eslint-visitor-keys: 3.3.0 dev: true - /@withtyped/postgres/0.6.0_@withtyped+server@0.6.0: - resolution: {integrity: sha512-Mq4/beT7vqtaxbNeFpP2mananch9OauwbQdvNR8YVaoolgPycxGuTB0LvnsLa4/7r4KQORQJcCxj+fckqkOFwA==} + /@withtyped/postgres/0.7.0_@withtyped+server@0.7.0: + resolution: {integrity: sha512-D6bI+ols0mtNvaTUp4IzNHAQtbqdakNTZgQ0E0KbVMvdfq0fhOFUaTKANRPjMs4rsyfAETfPzjt3B/Ij47ZiMA==} peerDependencies: - '@withtyped/server': ^0.6.0 + '@withtyped/server': ^0.7.0 dependencies: '@types/pg': 8.6.6 - '@withtyped/server': 0.6.0 + '@withtyped/server': 0.7.0 '@withtyped/shared': 0.2.0 pg: 8.8.0 transitivePeerDependencies: - pg-native dev: false - /@withtyped/server/0.6.0: - resolution: {integrity: sha512-p4rlk2EIq1zjQnwDe6cDNGl3VYJQL+sSxgoCfn9wqinExQ1ReegwujOBXaBuk/LQZ0HtSqDV0ayIE/NB8AQZew==} + /@withtyped/server/0.7.0: + resolution: {integrity: sha512-UVW6cOJyOBDfGiSoMg2asYsKqmzL7+UPaYwqW+oxZtvlUabaCekVKTBH3l8tm7zhiOluZg9FD78t0DVuEyQTMw==} dependencies: '@withtyped/shared': 0.2.0 dev: false