0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(cloud): implement request auth

This commit is contained in:
Gao Sun 2023-02-19 11:31:16 +08:00
parent 9a3b3f4bae
commit 12377665b4
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
18 changed files with 201 additions and 43 deletions

View file

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

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 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<RequestContext>()
.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',

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;
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<InputContext extends RequestContext>(
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);
}

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 '/'
*/
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<InputContext extends RequestContext>({
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);
}

View file

@ -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));

View file

@ -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<WithAuthContext, '/api'>('/api').pack(tenants);
export default router;

View file

@ -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<WithAuthContext, '/tenants'>('/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 }) => ({

View file

@ -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 '/';
}

View file

@ -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",

View file

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

View file

@ -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"
}
}

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 './api/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/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
@ -4590,21 +4592,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