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:
parent
9a3b3f4bae
commit
12377665b4
18 changed files with 201 additions and 43 deletions
|
@ -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"
|
||||
},
|
||||
|
|
15
packages/cloud/src/env-set/index.ts
Normal file
15
packages/cloud/src/env-set/index.ts
Normal 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;
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
|
|
87
packages/cloud/src/middleware/with-auth.ts
Normal file
87
packages/cloud/src/middleware/with-auth.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
31
packages/cloud/src/middleware/with-pathname.ts
Normal file
31
packages/cloud/src/middleware/with-pathname.ts
Normal 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);
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }) => ({
|
||||
|
|
|
@ -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 '/';
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -53,6 +53,6 @@
|
|||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc",
|
||||
"dependencies": {
|
||||
"@withtyped/server": "^0.6.0"
|
||||
"@withtyped/server": "^0.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
1
packages/schemas/src/consts/cloud.ts
Normal file
1
packages/schemas/src/consts/cloud.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const cloudApiIndicator = 'https://cloud.logto.io/api';
|
1
packages/schemas/src/consts/index.ts
Normal file
1
packages/schemas/src/consts/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './cloud.js';
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue