@eduzz/miau-client 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$types.log +1 -1
- package/dist/MiauClient.d.ts +3 -4
- package/dist/functions.d.ts +3 -0
- package/dist/functions.test.d.ts +1 -0
- package/dist/index.js +63 -38
- package/dist/index.js.map +3 -3
- package/dist/middleware.d.ts +1 -1
- package/package.json +3 -2
- package/scripts/should-release.sh +11 -1
- package/src/MiauClient.ts +38 -34
- package/src/functions.test.ts +76 -0
- package/src/functions.ts +38 -0
- package/src/middleware.ts +15 -15
package/dist/middleware.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RequestHandler, type NextFunction, type Request, type Response } from 'express';
|
|
2
|
-
import type
|
|
2
|
+
import { type MiauApplication } from '@eduzz/miau-types';
|
|
3
3
|
import type { MiauClient } from './MiauClient';
|
|
4
4
|
export type RequestAugmentation<T> = (data: {
|
|
5
5
|
req: Request;
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eduzz/miau-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Eduzz Miau Client",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"lint": "eslint && tsc --noEmit",
|
|
9
|
+
"test": "tsx --test src/**/*.test.ts",
|
|
9
10
|
"build": "esbuild src/index.ts --bundle --sourcemap --platform=node --target=es2020 --outfile=dist/index.js",
|
|
10
11
|
"build:types": "tsc --emitDeclarationOnly --outDir dist"
|
|
11
12
|
},
|
|
@@ -13,4 +14,4 @@
|
|
|
13
14
|
"@eduzz/miau-types": "workspace:*",
|
|
14
15
|
"chokidar-cli": "^3.0.0"
|
|
15
16
|
}
|
|
16
|
-
}
|
|
17
|
+
}
|
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
set -e
|
|
4
4
|
|
|
5
5
|
PACKAGE_JSON=$1
|
|
6
|
+
BRANCH_NAME=$2
|
|
7
|
+
RUN_NUMBER=$3
|
|
8
|
+
|
|
9
|
+
if [[ ! "$BRANCH_NAME" =~ "master" ]]; then
|
|
10
|
+
echo "Branch is not master, release candidate versioning will be applied."
|
|
11
|
+
VERSION=$(jq -r .version "$PACKAGE_JSON")
|
|
12
|
+
VERSION_RC="${VERSION}-rc.${RUN_NUMBER}"
|
|
13
|
+
jq --arg version "$VERSION_RC" '.version = $version' "$PACKAGE_JSON" > "$PACKAGE_JSON.tmp"
|
|
14
|
+
mv "$PACKAGE_JSON.tmp" "$PACKAGE_JSON"
|
|
15
|
+
fi
|
|
6
16
|
|
|
7
17
|
if [ -z "$PACKAGE_JSON" ]; then
|
|
8
18
|
echo "Usage: $0 <package-json-path>"
|
|
@@ -19,7 +29,7 @@ REMOTE_VERSION=$(npm view "$NAME" version 2>/dev/null || echo "")
|
|
|
19
29
|
|
|
20
30
|
echo "NPM Version: $REMOTE_VERSION"
|
|
21
31
|
|
|
22
|
-
if [ "$VERSION" = "$REMOTE_VERSION" ] || [ "$(printf '%s\n%s' "$REMOTE_VERSION" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]; then
|
|
32
|
+
if [ "$VERSION" = "$REMOTE_VERSION" ] || { [[ "$REMOTE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ "$(printf '%s\n%s' "$REMOTE_VERSION" "$VERSION" | sort -V | tail -n1)" != "$VERSION" ]; }; then
|
|
23
33
|
echo "Version already published, skipping..."
|
|
24
34
|
exit 1
|
|
25
35
|
fi
|
package/src/MiauClient.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type RequestHandler } from 'express';
|
|
|
4
4
|
import jwt from 'jsonwebtoken';
|
|
5
5
|
import { JwksClient } from 'jwks-rsa';
|
|
6
6
|
|
|
7
|
-
import { type SecretEnv, type Permission, SecretEnvValues } from '@eduzz/miau-types';
|
|
7
|
+
import { type SecretEnv, type Permission, SecretEnvValues, type Resource } from '@eduzz/miau-types';
|
|
8
8
|
|
|
9
9
|
import { miauMiddleware, type RequestAugmentation } from './middleware';
|
|
10
10
|
|
|
@@ -12,6 +12,8 @@ type MiauClientConfig = { apiUrl: string; appSecret: string; environment: Secret
|
|
|
12
12
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
13
13
|
type FetchInit = Parameters<typeof fetch>[1];
|
|
14
14
|
|
|
15
|
+
const requestsCache: Map<string, Promise<{ data: any; headers: Headers }>> = new Map();
|
|
16
|
+
|
|
15
17
|
const reusableFetch = async <T>(input: FetchInput, init?: FetchInit): Promise<{ data: T; headers: Headers }> => {
|
|
16
18
|
return new Promise(async (resolve, reject) => {
|
|
17
19
|
try {
|
|
@@ -25,13 +27,36 @@ const reusableFetch = async <T>(input: FetchInput, init?: FetchInit): Promise<{
|
|
|
25
27
|
});
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
const cacheableFetch = async <T>(input: FetchInput, init?: FetchInit) => {
|
|
31
|
+
const cacheKeyData = JSON.stringify({ input, init });
|
|
32
|
+
const cacheKey = crypto.createHash('sha256').update(cacheKeyData).digest('hex');
|
|
33
|
+
const cachedResponse = requestsCache.get(cacheKey);
|
|
34
|
+
|
|
35
|
+
if (cachedResponse) {
|
|
36
|
+
const { data, headers } = await cachedResponse;
|
|
37
|
+
const expiresAt = expiresHeaderToUnixtime(headers.get('Expires'));
|
|
38
|
+
|
|
39
|
+
if (expiresAt > Date.now()) {
|
|
40
|
+
return data as T;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
requestsCache.delete(cacheKey);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const newRequest = reusableFetch<T>(input, init);
|
|
47
|
+
requestsCache.set(cacheKey, newRequest);
|
|
48
|
+
return (await newRequest).data as T;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const expiresHeaderToUnixtime = (expires: string | null): number => {
|
|
52
|
+
return expires ? new Date(expires).getTime() : Date.now() + 60_000;
|
|
53
|
+
};
|
|
54
|
+
|
|
28
55
|
export class MiauClient {
|
|
29
56
|
private config: MiauClientConfig;
|
|
30
57
|
private jwtToken: string | undefined;
|
|
31
58
|
private jwksClient: JwksClient | undefined;
|
|
32
59
|
private basicAuthToken: string;
|
|
33
|
-
private permissionsCache: Map<string, { data: Permission; expiresAt: number }> = new Map();
|
|
34
|
-
private permissionsRequests: Map<string, Promise<{ data: Permission; headers: Headers }>> = new Map();
|
|
35
60
|
|
|
36
61
|
constructor(config: MiauClientConfig) {
|
|
37
62
|
if (!config.apiUrl || !config.appSecret || !config.environment) {
|
|
@@ -95,41 +120,16 @@ export class MiauClient {
|
|
|
95
120
|
return miauMiddleware<T>(this, config?.requestAugmentation, config?.fallbackMiddleware);
|
|
96
121
|
}
|
|
97
122
|
|
|
98
|
-
public async
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (expiresAt > Date.now()) {
|
|
103
|
-
this.permissionsRequests.delete(targetAppId);
|
|
104
|
-
return data;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const request = await this.requestPermissions(targetAppId);
|
|
109
|
-
|
|
110
|
-
if (request !== undefined) {
|
|
111
|
-
const { data, headers } = request;
|
|
112
|
-
this.permissionsCache.set(targetAppId, {
|
|
113
|
-
data,
|
|
114
|
-
expiresAt: headers.has('Expires') ? new Date(headers.get('Expires')!).getTime() : Date.now() + 60_000 // Default to 1 minute if no Expires header
|
|
115
|
-
});
|
|
116
|
-
return data;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
throw new Error(`Failed to fetch permissions for app Id: ${targetAppId}`);
|
|
123
|
+
public async getResources() {
|
|
124
|
+
return cacheableFetch<Resource[]>(this.getResourcesUrl(), {
|
|
125
|
+
headers: { Authorization: `Basic ${this.basicAuthToken}` }
|
|
126
|
+
});
|
|
120
127
|
}
|
|
121
128
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return this.permissionsRequests.get(sourceAppId);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const request = reusableFetch<Permission>(`${this.getPermissionsUrl()}/${sourceAppId}`, {
|
|
129
|
+
public async getPermissions(targetAppId: string) {
|
|
130
|
+
return cacheableFetch<Permission>(`${this.getPermissionsUrl()}/${targetAppId}`, {
|
|
128
131
|
headers: { Authorization: `Basic ${this.basicAuthToken}` }
|
|
129
132
|
});
|
|
130
|
-
|
|
131
|
-
this.permissionsRequests.set(sourceAppId, request);
|
|
132
|
-
return request;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
private getApiJwtUrl = () => {
|
|
@@ -140,6 +140,10 @@ export class MiauClient {
|
|
|
140
140
|
return `${this.config.apiUrl}/v1/permissions`;
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
+
private getResourcesUrl = () => {
|
|
144
|
+
return `${this.config.apiUrl}/v1/resources`;
|
|
145
|
+
};
|
|
146
|
+
|
|
143
147
|
private getJwksUrl = () => {
|
|
144
148
|
return `${this.config.apiUrl}/v1/jwks.json`;
|
|
145
149
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { equal } from 'node:assert';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { type Resource } from '@eduzz/miau-types';
|
|
5
|
+
|
|
6
|
+
import { isResourceAllowed, wildcardToRegex } from './functions';
|
|
7
|
+
|
|
8
|
+
test('wildcardToRegex should convert wildcard to regex', () => {
|
|
9
|
+
const pattern = '/v1/resources/*/details';
|
|
10
|
+
const pattern2 = '/v1/resources/*';
|
|
11
|
+
const regex = wildcardToRegex(pattern);
|
|
12
|
+
const regex2 = wildcardToRegex(pattern2);
|
|
13
|
+
equal(regex.test('/v1/resources/123/details'), true);
|
|
14
|
+
equal(regex.test('/v1/resources/abc/details'), true);
|
|
15
|
+
equal(regex.test('/v1/resources/details'), false);
|
|
16
|
+
equal(regex.test('/v1/resources/123/other'), false);
|
|
17
|
+
equal(regex2.test('/v1/resources/123'), true);
|
|
18
|
+
equal(regex2.test('/v1/resources/abc'), true);
|
|
19
|
+
equal(regex2.test('/v1/resources/123/other'), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const resources1: Resource[] = [
|
|
23
|
+
{ protocol: 'http', method: 'GET', path: '/v1/resources' },
|
|
24
|
+
{ protocol: 'http', method: 'GET', path: '/v1/another' },
|
|
25
|
+
{ protocol: 'http', method: 'GET', path: '/v1/resources/*' },
|
|
26
|
+
{ protocol: 'http', method: 'GET', path: '/v1/resources/fixed' },
|
|
27
|
+
{ protocol: 'http', method: 'GET', path: '/v1/chumbated/chumbated' },
|
|
28
|
+
{ protocol: 'http', method: 'GET', path: '/v1/*/*' },
|
|
29
|
+
{ protocol: 'http', method: 'POST', path: '/v1/resources' },
|
|
30
|
+
{ protocol: 'http', method: 'PUT', path: '/v1/resources/*' },
|
|
31
|
+
{ protocol: 'http', method: 'DELETE', path: '/v1/resources/*' }
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const permissions1: Resource[] = [
|
|
35
|
+
{ protocol: 'http', method: 'GET', path: '/v1/resources' },
|
|
36
|
+
{ protocol: 'http', method: 'GET', path: '/v1/resources/*' },
|
|
37
|
+
{ protocol: 'http', method: 'POST', path: '/v1/resources' },
|
|
38
|
+
{ protocol: 'http', method: 'PUT', path: '/v1/resources/*' },
|
|
39
|
+
{ protocol: 'http', method: 'DELETE', path: '/v1/resources/*' }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test('should access route', () => {
|
|
43
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/resources' };
|
|
44
|
+
equal(isResourceAllowed(resource, resources1, permissions1), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('should NOT access route with wrong method', () => {
|
|
48
|
+
const resource: Resource = { protocol: 'http', method: 'POST', path: '/v1/resources/123' };
|
|
49
|
+
equal(isResourceAllowed(resource, resources1, permissions1), false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('should NOT access route with no permission', () => {
|
|
53
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/another' };
|
|
54
|
+
equal(isResourceAllowed(resource, resources1, permissions1), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('should access wildcard route', () => {
|
|
58
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/resources/12345' };
|
|
59
|
+
equal(isResourceAllowed(resource, resources1, permissions1), true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('should NOT access fixed route despite wildcard', () => {
|
|
63
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/resources/fixed' };
|
|
64
|
+
equal(isResourceAllowed(resource, resources1, permissions1), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('should NOT access chumbated route with exact match', () => {
|
|
68
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/chumbated/chumbated' };
|
|
69
|
+
equal(isResourceAllowed(resource, resources1, permissions1), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should access double wildcard route', () => {
|
|
73
|
+
const permissions: Resource[] = [{ protocol: 'http', method: 'GET', path: '/v1/*/*' }];
|
|
74
|
+
const resource: Resource = { protocol: 'http', method: 'GET', path: '/v1/another/route' };
|
|
75
|
+
equal(isResourceAllowed(resource, resources1, permissions), true);
|
|
76
|
+
});
|
package/src/functions.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Resource } from '@eduzz/miau-types';
|
|
2
|
+
|
|
3
|
+
export const wildcardToRegex = (pattern: string) => {
|
|
4
|
+
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
|
|
5
|
+
const withWildcards = escaped.replace(/\*/g, '[^/]+');
|
|
6
|
+
const regexStr = `^${withWildcards}$`;
|
|
7
|
+
return new RegExp(regexStr);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const isResourceAllowed = (
|
|
11
|
+
resource: Resource,
|
|
12
|
+
resources: Resource[],
|
|
13
|
+
permittedResources: Resource[]
|
|
14
|
+
): boolean => {
|
|
15
|
+
const hasExactMatch = resources.some((res: Resource) => {
|
|
16
|
+
return (
|
|
17
|
+
res.method.toLowerCase() === resource.method.toLowerCase() &&
|
|
18
|
+
res.path.toLowerCase() === resource.path.toLowerCase()
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (hasExactMatch) {
|
|
23
|
+
return permittedResources.some((perm: Resource) => {
|
|
24
|
+
return (
|
|
25
|
+
perm.method.toLowerCase() === resource.method.toLowerCase() &&
|
|
26
|
+
perm.path.toLowerCase() === resource.path.toLowerCase()
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return permittedResources.some((permission: Resource) => {
|
|
32
|
+
const permissionRegex = wildcardToRegex(permission.path);
|
|
33
|
+
return (
|
|
34
|
+
permission.method.toLowerCase() === resource.method.toLowerCase() &&
|
|
35
|
+
permissionRegex.test(resource.path.toLowerCase())
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
};
|
package/src/middleware.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type RequestHandler, type NextFunction, type Request, type Response } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
|
|
4
|
-
import type
|
|
4
|
+
import { type Resource, type MiauApplication, type MiauClientToken } from '@eduzz/miau-types';
|
|
5
5
|
|
|
6
|
+
import { isResourceAllowed } from './functions';
|
|
6
7
|
import type { MiauClient } from './MiauClient';
|
|
7
8
|
|
|
8
9
|
class HttpError extends Error {
|
|
@@ -19,13 +20,6 @@ class HttpError extends Error {
|
|
|
19
20
|
|
|
20
21
|
export type RequestAugmentation<T> = (data: { req: Request; app: MiauApplication; meta: T }) => void;
|
|
21
22
|
|
|
22
|
-
const wildcardToRegex = (pattern: string) => {
|
|
23
|
-
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
|
|
24
|
-
const withWildcards = escaped.replace(/\*/g, '[^/]+');
|
|
25
|
-
const regexStr = `^${withWildcards}$`;
|
|
26
|
-
return new RegExp(regexStr);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
23
|
export const miauMiddleware = <T>(
|
|
30
24
|
miauClient: MiauClient,
|
|
31
25
|
requestAugmentation?: RequestAugmentation<T>,
|
|
@@ -69,20 +63,26 @@ export const miauMiddleware = <T>(
|
|
|
69
63
|
);
|
|
70
64
|
}
|
|
71
65
|
|
|
66
|
+
const resources = await miauClient.getResources();
|
|
67
|
+
|
|
68
|
+
if (!resources) {
|
|
69
|
+
throw new HttpError(401, 'Unauthorized', 'No resources found for this application');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
72
|
const permission = await miauClient.getPermissions(application.id);
|
|
73
73
|
|
|
74
74
|
if (!permission) {
|
|
75
75
|
throw new HttpError(401, 'Unauthorized', 'No permissions found for this application');
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const
|
|
78
|
+
const permittedResources = permission?.resources || [];
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
if (!permittedResources.length) {
|
|
81
|
+
throw new HttpError(403, 'Forbidden', 'No resources are permitted for this application');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const resource = { protocol: 'http', method: req.method, path: req.path } as Resource;
|
|
85
|
+
const isAllowed = isResourceAllowed(resource, resources, permittedResources);
|
|
86
86
|
|
|
87
87
|
if (!isAllowed) {
|
|
88
88
|
throw new HttpError(403, 'Forbidden', `You do not have permission to access ${req.method} ${req.path}`);
|