0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

Merge pull request #3147 from logto-io/gao-guard-api

refactor(cloud): implement request auth
This commit is contained in:
Gao Sun 2023-02-20 13:15:29 +08:00 committed by GitHub
commit 6f4063609c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 201 additions and 43 deletions

View file

@ -22,12 +22,13 @@
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*", "@logto/shared": "workspace:*",
"@silverhand/essentials": "2.2.0", "@silverhand/essentials": "2.2.0",
"@withtyped/postgres": "^0.6.0", "@withtyped/postgres": "^0.7.0",
"@withtyped/server": "^0.6.0", "@withtyped/server": "^0.7.0",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"find-up": "^6.3.0", "find-up": "^6.3.0",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"jose": "^4.11.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

View file

@ -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;
},
};

View file

@ -1,23 +1,34 @@
import { cloudApiIndicator } from '@logto/schemas';
import type { RequestContext } from '@withtyped/server';
import createServer, { compose, withRequest } from '@withtyped/server'; import createServer, { compose, withRequest } from '@withtyped/server';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { findUp } from 'find-up'; import { findUp } from 'find-up';
import withAuth from './middleware/with-auth.js';
import withHttpProxy from './middleware/with-http-proxy.js'; import withHttpProxy from './middleware/with-http-proxy.js';
import withPathname from './middleware/with-pathname.js';
import withSpa from './middleware/with-spa.js'; import withSpa from './middleware/with-spa.js';
dotenv.config({ path: await findUp('.env', {}) }); dotenv.config({ path: await findUp('.env', {}) });
const { EnvSet } = await import('./env-set/index.js');
const { default: router } = await import('./routes/index.js'); const { default: router } = await import('./routes/index.js');
const isProduction = process.env.NODE_ENV === 'production';
const ignorePathnames = ['/api']; const ignorePathnames = ['/api'];
const { listen } = createServer({ const { listen } = createServer({
port: 3003, port: 3003,
composer: compose(withRequest()) composer: compose(withRequest())
.and(router.routes())
.and( .and(
isProduction withPathname(
'/api',
compose<RequestContext>()
.and(withAuth({ endpoint: EnvSet.global.logtoEndpoint, audience: cloudApiIndicator }))
.and(router.routes())
)
)
.and(
EnvSet.isProduction
? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames }) ? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames })
: withHttpProxy('/', { : withHttpProxy('/', {
target: 'http://localhost:5002', target: 'http://localhost:5002',

View file

@ -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 = RequestContext> = 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<InputContext extends RequestContext>({
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<WithAuthContext<InputContext>>) => {
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);
}
};
}

View file

@ -8,9 +8,7 @@ import { matchPathname } from '#src/utils/url.js';
const { createProxy } = HttpProxy; const { createProxy } = HttpProxy;
export type WithHttpProxyOptions = ServerOptions & { export type WithHttpProxyOptions = ServerOptions & {
/** /** An array of pathname prefixes to ignore. */
* An array of pathname prefixes to ignore.
*/
ignorePathnames?: string[]; ignorePathnames?: string[];
}; };
@ -36,9 +34,9 @@ export default function withHttpProxy<InputContext extends RequestContext>(
request: { url }, request: { url },
} = context; } = 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); return next(context);
} }

View file

@ -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<InputContext, InputContext | OutputContext>) {
return async (
context: InputContext,
next: NextFunction<InputContext | OutputContext>,
httpContext: HttpContext
) => {
if (!matchPathname(pathname, context.request.url.pathname)) {
return next(context);
}
return run(context, next, httpContext);
};
}

View file

@ -21,9 +21,7 @@ export type WithSpaConfig = {
* @default '/' * @default '/'
*/ */
pathname?: string; pathname?: string;
/** /** An array of pathname prefixes to ignore. */
* An array of pathname prefixes to ignore.
*/
ignorePathnames?: string[]; ignorePathnames?: string[];
/** /**
* The path to file to serve when the given path cannot be found in the file system. * 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<InputContext extends RequestContext>({
request: { url }, request: { url },
} = context; } = 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); return next(context);
} }

View file

@ -1,5 +1,6 @@
import { createQueryClient } from '@withtyped/postgres'; import { createQueryClient } from '@withtyped/postgres';
import { EnvSet } from '#src/env-set/index.js';
import { parseDsn } from '#src/utils/postgres.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));

View file

@ -1,7 +1,9 @@
import { createRouter } from '@withtyped/server'; import { createRouter } from '@withtyped/server';
import type { WithAuthContext } from '#src/middleware/with-auth.js';
import { tenants } from './tenants.js'; import { tenants } from './tenants.js';
const router = createRouter('/api').pack(tenants); const router = createRouter<WithAuthContext, '/api'>('/api').pack(tenants);
export default router; export default router;

View file

@ -1,17 +1,18 @@
import { Router } from '@withtyped/server'; import { createRouter } from '@withtyped/server';
import { z } from 'zod'; import { z } from 'zod';
import type { WithAuthContext } from '#src/middleware/with-auth.js';
import { client } from '#src/queries/index.js'; import { client } from '#src/queries/index.js';
import { createTenantsQueries } from '#src/queries/tenants.js'; import { createTenantsQueries } from '#src/queries/tenants.js';
import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js'; import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js';
const { getManagementApiLikeIndicatorsForUser } = createTenantsQueries(client); const { getManagementApiLikeIndicatorsForUser } = createTenantsQueries(client);
export const tenants = new Router('/tenants').get( export const tenants = createRouter<WithAuthContext, '/tenants'>('/tenants').get(
'/', '/',
{ response: z.object({ id: z.string(), indicator: z.string() }).array() }, { response: z.object({ id: z.string(), indicator: z.string() }).array() },
async (context, next) => { async (context, next) => {
const { rows } = await getManagementApiLikeIndicatorsForUser('some_user_id'); const { rows } = await getManagementApiLikeIndicatorsForUser(context.auth.id);
const tenants = rows const tenants = rows
.map(({ indicator }) => ({ .map(({ indicator }) => ({

View file

@ -6,10 +6,18 @@ export const normalizePath = (pathLike: string) => {
return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value; 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 toMatchPathname = normalizePath(toMatch);
const normalized = normalizePath(pathname); const normalized = normalizePath(pathname);
if (ignorePathnames.some((prefix) => matchPathname(prefix, pathname))) {
return false;
}
if (normalized === toMatchPathname) { if (normalized === toMatchPathname) {
return '/'; return '/';
} }

View file

@ -35,8 +35,8 @@
"@logto/schemas": "workspace:*", "@logto/schemas": "workspace:*",
"@logto/shared": "workspace:*", "@logto/shared": "workspace:*",
"@silverhand/essentials": "2.2.0", "@silverhand/essentials": "2.2.0",
"@withtyped/postgres": "^0.6.0", "@withtyped/postgres": "^0.7.0",
"@withtyped/server": "^0.6.0", "@withtyped/server": "^0.7.0",
"chalk": "^5.0.0", "chalk": "^5.0.0",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",

View file

@ -53,6 +53,6 @@
}, },
"prettier": "@silverhand/eslint-config/.prettierrc", "prettier": "@silverhand/eslint-config/.prettierrc",
"dependencies": { "dependencies": {
"@withtyped/server": "^0.6.0" "@withtyped/server": "^0.7.0"
} }
} }

View file

@ -84,7 +84,7 @@
"@logto/language-kit": "workspace:*", "@logto/language-kit": "workspace:*",
"@logto/phrases": "workspace:*", "@logto/phrases": "workspace:*",
"@logto/phrases-ui": "workspace:*", "@logto/phrases-ui": "workspace:*",
"@withtyped/server": "^0.6.0", "@withtyped/server": "^0.7.0",
"zod": "^3.20.2" "zod": "^3.20.2"
} }
} }

View file

@ -0,0 +1 @@
export const cloudApiIndicator = 'https://cloud.logto.io/api';

View file

@ -0,0 +1 @@
export * from './cloud.js';

View file

@ -3,3 +3,4 @@ export * from './db-entries/index.js';
export * from './types/index.js'; export * from './types/index.js';
export * from './api/index.js'; export * from './api/index.js';
export * from './seeds/index.js'; export * from './seeds/index.js';
export * from './consts/index.js';

View file

@ -114,13 +114,14 @@ importers:
'@types/http-proxy': ^1.17.9 '@types/http-proxy': ^1.17.9
'@types/mime-types': ^2.1.1 '@types/mime-types': ^2.1.1
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@withtyped/postgres': ^0.6.0 '@withtyped/postgres': ^0.7.0
'@withtyped/server': ^0.6.0 '@withtyped/server': ^0.7.0
chalk: ^5.0.0 chalk: ^5.0.0
dotenv: ^16.0.0 dotenv: ^16.0.0
eslint: ^8.21.0 eslint: ^8.21.0
find-up: ^6.3.0 find-up: ^6.3.0
http-proxy: ^1.18.1 http-proxy: ^1.18.1
jose: ^4.11.0
lint-staged: ^13.0.0 lint-staged: ^13.0.0
mime-types: ^2.1.35 mime-types: ^2.1.35
nodemon: ^2.0.19 nodemon: ^2.0.19
@ -131,12 +132,13 @@ importers:
'@logto/schemas': link:../schemas '@logto/schemas': link:../schemas
'@logto/shared': link:../shared '@logto/shared': link:../shared
'@silverhand/essentials': 2.2.0 '@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
'@withtyped/server': 0.6.0 '@withtyped/server': 0.7.0
chalk: 5.1.2 chalk: 5.1.2
dotenv: 16.0.0 dotenv: 16.0.0
find-up: 6.3.0 find-up: 6.3.0
http-proxy: 1.18.1 http-proxy: 1.18.1
jose: 4.11.1
mime-types: 2.1.35 mime-types: 2.1.35
zod: 3.20.2 zod: 3.20.2
devDependencies: devDependencies:
@ -329,8 +331,8 @@ importers:
'@types/semver': ^7.3.12 '@types/semver': ^7.3.12
'@types/sinon': ^10.0.13 '@types/sinon': ^10.0.13
'@types/supertest': ^2.0.11 '@types/supertest': ^2.0.11
'@withtyped/postgres': ^0.6.0 '@withtyped/postgres': ^0.7.0
'@withtyped/server': ^0.6.0 '@withtyped/server': ^0.7.0
chalk: ^5.0.0 chalk: ^5.0.0
clean-deep: ^3.4.0 clean-deep: ^3.4.0
copyfiles: ^2.4.1 copyfiles: ^2.4.1
@ -389,8 +391,8 @@ importers:
'@logto/schemas': link:../schemas '@logto/schemas': link:../schemas
'@logto/shared': link:../shared '@logto/shared': link:../shared
'@silverhand/essentials': 2.2.0 '@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
'@withtyped/server': 0.6.0 '@withtyped/server': 0.7.0
chalk: 5.1.2 chalk: 5.1.2
clean-deep: 3.4.0 clean-deep: 3.4.0
date-fns: 2.29.3 date-fns: 2.29.3
@ -538,7 +540,7 @@ importers:
'@types/jest': ^29.1.2 '@types/jest': ^29.1.2
'@types/jest-environment-puppeteer': ^5.0.2 '@types/jest-environment-puppeteer': ^5.0.2
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@withtyped/server': ^0.6.0 '@withtyped/server': ^0.7.0
dotenv: ^16.0.0 dotenv: ^16.0.0
eslint: ^8.34.0 eslint: ^8.34.0
got: ^12.5.3 got: ^12.5.3
@ -552,7 +554,7 @@ importers:
text-encoder: ^0.0.4 text-encoder: ^0.0.4
typescript: ^4.9.4 typescript: ^4.9.4
dependencies: dependencies:
'@withtyped/server': 0.6.0 '@withtyped/server': 0.7.0
devDependencies: devDependencies:
'@jest/types': 29.1.2 '@jest/types': 29.1.2
'@logto/connector-kit': link:../toolkit/connector-kit '@logto/connector-kit': link:../toolkit/connector-kit
@ -645,7 +647,7 @@ importers:
'@types/jest': ^29.1.2 '@types/jest': ^29.1.2
'@types/node': ^18.11.18 '@types/node': ^18.11.18
'@types/pluralize': ^0.0.29 '@types/pluralize': ^0.0.29
'@withtyped/server': ^0.6.0 '@withtyped/server': ^0.7.0
camelcase: ^7.0.0 camelcase: ^7.0.0
eslint: ^8.34.0 eslint: ^8.34.0
jest: ^29.1.2 jest: ^29.1.2
@ -663,7 +665,7 @@ importers:
'@logto/language-kit': link:../toolkit/language-kit '@logto/language-kit': link:../toolkit/language-kit
'@logto/phrases': link:../phrases '@logto/phrases': link:../phrases
'@logto/phrases-ui': link:../phrases-ui '@logto/phrases-ui': link:../phrases-ui
'@withtyped/server': 0.6.0 '@withtyped/server': 0.7.0
zod: 3.20.2 zod: 3.20.2
devDependencies: devDependencies:
'@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy '@silverhand/eslint-config': 2.0.1_kjzxg5porcw5dx54sezsklj5cy
@ -4569,21 +4571,21 @@ packages:
eslint-visitor-keys: 3.3.0 eslint-visitor-keys: 3.3.0
dev: true dev: true
/@withtyped/postgres/0.6.0_@withtyped+server@0.6.0: /@withtyped/postgres/0.7.0_@withtyped+server@0.7.0:
resolution: {integrity: sha512-Mq4/beT7vqtaxbNeFpP2mananch9OauwbQdvNR8YVaoolgPycxGuTB0LvnsLa4/7r4KQORQJcCxj+fckqkOFwA==} resolution: {integrity: sha512-D6bI+ols0mtNvaTUp4IzNHAQtbqdakNTZgQ0E0KbVMvdfq0fhOFUaTKANRPjMs4rsyfAETfPzjt3B/Ij47ZiMA==}
peerDependencies: peerDependencies:
'@withtyped/server': ^0.6.0 '@withtyped/server': ^0.7.0
dependencies: dependencies:
'@types/pg': 8.6.6 '@types/pg': 8.6.6
'@withtyped/server': 0.6.0 '@withtyped/server': 0.7.0
'@withtyped/shared': 0.2.0 '@withtyped/shared': 0.2.0
pg: 8.8.0 pg: 8.8.0
transitivePeerDependencies: transitivePeerDependencies:
- pg-native - pg-native
dev: false dev: false
/@withtyped/server/0.6.0: /@withtyped/server/0.7.0:
resolution: {integrity: sha512-p4rlk2EIq1zjQnwDe6cDNGl3VYJQL+sSxgoCfn9wqinExQ1ReegwujOBXaBuk/LQZ0HtSqDV0ayIE/NB8AQZew==} resolution: {integrity: sha512-UVW6cOJyOBDfGiSoMg2asYsKqmzL7+UPaYwqW+oxZtvlUabaCekVKTBH3l8tm7zhiOluZg9FD78t0DVuEyQTMw==}
dependencies: dependencies:
'@withtyped/shared': 0.2.0 '@withtyped/shared': 0.2.0
dev: false dev: false