@nmbrco/server 0.0.1 → 0.1.0
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/CHANGELOG.md +10 -0
- package/README.md +40 -24
- package/dist/index.d.ts +1 -2
- package/dist/index.js +36 -12
- package/dist/index.js.map +1 -1
- package/dist/sign-request.d.ts +1 -1
- package/dist/sign.d.ts +7 -3
- package/dist/token.d.ts +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @nmbrco/server
|
|
2
2
|
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- CMP-3567: Accept employee and contractor requests in @nmbrco/server (#3504)
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Harden the sign middleware in @nmbrco/server (#3282)
|
|
12
|
+
|
|
3
13
|
## 0.0.1
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -29,46 +29,62 @@ app.post(
|
|
|
29
29
|
sign({
|
|
30
30
|
apiSecret: process.env.NMBR_API_SECRET!,
|
|
31
31
|
partnerId: process.env.NMBR_PARTNER_ID!,
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// Only for payroll admins. Return nothing for everyone else.
|
|
33
|
+
getCompanyId: (req: Request) =>
|
|
34
|
+
req.user.isPayrollAdmin ? req.user.nmbrCompanyId : undefined,
|
|
35
|
+
getEmployeeId: (req: Request) => req.user.nmbrEmployeeId,
|
|
36
|
+
getContractorId: (req: Request) => req.user.nmbrContractorId,
|
|
35
37
|
})
|
|
36
38
|
);
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
### Why `
|
|
41
|
+
### Why `sign` asks for the ids
|
|
40
42
|
|
|
41
|
-
The Nmbr API checks that the
|
|
43
|
+
The Nmbr API checks that the subject of a token belongs to your partner account. The subject is a company, an employee or a contractor. The API cannot know which of your users may act on which subject. `sign` signs the `sub` of a request only when a resolver returns that same id for the signed-in user. Then a user of your app cannot get a token for another company, employee or contractor under your partner account.
|
|
42
44
|
|
|
43
|
-
`
|
|
45
|
+
`admin:*` gives full payroll access to the company. Return the company id from `getCompanyId` only when the signed-in user is a payroll admin of that company. Do not return it for each user who works for the company. If you do, an employee of the company can request `admin:*` and get a token with full payroll access.
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
The Component sends the `scope` of each request. `sign` compares the `sub` to the ids of the resolvers for that scope:
|
|
48
|
+
|
|
49
|
+
| `scope` | `sign` signs the `sub` only if it equals | Access |
|
|
50
|
+
| ------------ | ---------------------------------------------- | --------------------------------------- |
|
|
51
|
+
| `admin:*` | `getCompanyId(req)` | Full payroll access to the company |
|
|
52
|
+
| `employee:*` | `getEmployeeId(req)` or `getContractorId(req)` | The data of that employee or contractor |
|
|
53
|
+
|
|
54
|
+
Give at least one resolver, and only the resolvers that your app needs. `sign` throws when you give none. A scope with no resolver is off, and `sign` responds `403` to it. A resolver can return a promise, for example when it reads the id from your database. Return nothing when the user has no such id. Do not throw. `sign` runs `getEmployeeId` and `getContractorId` for each `employee:*` request, so a `getEmployeeId` that throws for a contractor makes the request fail with `500`. Each resolver returns one id: the Component works on one company at a time. A person who is an employee and a contractor in the same company gets a token for each id.
|
|
55
|
+
|
|
56
|
+
`sign` signs only the scopes in the table, and responds `400` to another scope. A new scope comes in a new version of this package. If the Component gets `400` (`Unknown scope`), update this package.
|
|
57
|
+
|
|
58
|
+
Rate-limit the sign route. The Component calls it once for each API request, and each call runs the resolvers for its scope.
|
|
46
59
|
|
|
47
60
|
### What `sign` sets
|
|
48
61
|
|
|
49
|
-
The Component sends the claims for each API request. `sign` keeps only the
|
|
62
|
+
The Component sends the claims for each API request. `sign` keeps only the subject, scope, HTTP method, path and body hash from that message. It sets `iss` and `exp` itself:
|
|
63
|
+
|
|
64
|
+
| Claim | Value |
|
|
65
|
+
| -------- | ---------------------------------------------------------------- |
|
|
66
|
+
| `iss` | `partnerId` |
|
|
67
|
+
| `sub` | the company, employee or contractor, after a resolver returns it |
|
|
68
|
+
| `scope` | from the Component, when `sign` knows the scope |
|
|
69
|
+
| `exp` | 5 minutes from now, in seconds |
|
|
70
|
+
| `method` | from the Component |
|
|
71
|
+
| `uri` | from the Component (a path only) |
|
|
72
|
+
| `hash` | from the Component, when present |
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
| -------- | ---------------------------------------- |
|
|
53
|
-
| `iss` | `partnerId` |
|
|
54
|
-
| `sub` | the company, after `authorize` allows it |
|
|
55
|
-
| `scope` | from the Component, after `authorize` allows it |
|
|
56
|
-
| `exp` | 5 minutes from now |
|
|
57
|
-
| `method` | from the Component |
|
|
58
|
-
| `uri` | from the Component (a path only) |
|
|
59
|
-
| `hash` | from the Component, when present |
|
|
74
|
+
Tokens have no `iat` claim. Keep the clock of your server in sync with NTP. The API allows 60 seconds of difference. If your clock is more than 6 minutes slow, every token is already expired.
|
|
60
75
|
|
|
61
76
|
### Responses
|
|
62
77
|
|
|
63
|
-
| Status | When
|
|
64
|
-
| ------ |
|
|
65
|
-
| `201` | The token, as `text/plain`
|
|
66
|
-
| `400` | The body is not valid JSON,
|
|
67
|
-
| `403` | `
|
|
78
|
+
| Status | When |
|
|
79
|
+
| ------ | --------------------------------------------------------------------------------- |
|
|
80
|
+
| `201` | The token, as `text/plain` |
|
|
81
|
+
| `400` | The body is not valid JSON, has invalid claims, or has an unknown scope |
|
|
82
|
+
| `403` | The scope has no resolver, or the `sub` matches no resolver id |
|
|
83
|
+
| `415` | The `Content-Type` is not `application/json` |
|
|
68
84
|
| `413` | The body is larger than 8 KiB. `sign` stops reading it and closes the connection. |
|
|
69
|
-
| `405` | The request is not a `POST`
|
|
85
|
+
| `405` | The request is not a `POST` |
|
|
70
86
|
|
|
71
|
-
If
|
|
87
|
+
If a resolver throws, `sign` passes the error to `next` (Express), or responds `500` when there is no `next`.
|
|
72
88
|
|
|
73
89
|
### Other frameworks
|
|
74
90
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -13,33 +13,39 @@ var isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
|
|
|
13
13
|
var parseSignRequest = (body) => {
|
|
14
14
|
if (typeof body !== "object" || body === null) return void 0;
|
|
15
15
|
const { sub, method, uri, scope, hash } = body;
|
|
16
|
-
if (!isNonEmptyString(sub) || !isNonEmptyString(method) || !METHODS.includes(method) || !isNonEmptyString(uri) || !uri.startsWith("/") || uri.startsWith("//") || !isNonEmptyString(scope) || hash !== void 0 &&
|
|
16
|
+
if (!isNonEmptyString(sub) || !isNonEmptyString(method) || !METHODS.includes(method) || !isNonEmptyString(uri) || !uri.startsWith("/") || uri.startsWith("//") || !isNonEmptyString(scope) || hash !== void 0 && !isNonEmptyString(hash)) return;
|
|
17
17
|
return {
|
|
18
|
-
|
|
18
|
+
sub,
|
|
19
19
|
method,
|
|
20
20
|
uri,
|
|
21
21
|
scope,
|
|
22
|
-
|
|
22
|
+
hash
|
|
23
23
|
};
|
|
24
24
|
};
|
|
25
25
|
//#endregion
|
|
26
26
|
//#region src/token.ts
|
|
27
27
|
var TOKEN_TTL = "5m";
|
|
28
28
|
var createSigningKey = (apiSecret) => new TextEncoder().encode(createHash("sha256").update(apiSecret).digest("hex"));
|
|
29
|
-
var signToken = (partnerId, {
|
|
29
|
+
var signToken = (partnerId, { sub, method, uri, scope, hash }, key) => new SignJWT({
|
|
30
30
|
uri,
|
|
31
31
|
method,
|
|
32
32
|
scope,
|
|
33
|
-
|
|
33
|
+
hash
|
|
34
34
|
}).setProtectedHeader({
|
|
35
35
|
alg: "HS256",
|
|
36
36
|
typ: "JWT"
|
|
37
|
-
}).setIssuer(partnerId).setSubject(
|
|
37
|
+
}).setIssuer(partnerId).setSubject(sub).setExpirationTime(TOKEN_TTL).sign(key);
|
|
38
38
|
//#endregion
|
|
39
39
|
//#region src/sign.ts
|
|
40
|
+
var RESOLVER_NAMES = [
|
|
41
|
+
"getCompanyId",
|
|
42
|
+
"getEmployeeId",
|
|
43
|
+
"getContractorId"
|
|
44
|
+
];
|
|
40
45
|
var MAX_BODY_BYTES = 8192;
|
|
41
46
|
var LINGER_MS = 1e3;
|
|
42
47
|
var TOO_LARGE = Symbol("TOO_LARGE");
|
|
48
|
+
var isJson = (req) => req.headers["content-type"]?.split(";")[0].trim().toLowerCase() === "application/json";
|
|
43
49
|
var parseJson = (text) => {
|
|
44
50
|
try {
|
|
45
51
|
return JSON.parse(text);
|
|
@@ -69,6 +75,7 @@ var readBody = async (req) => {
|
|
|
69
75
|
if (typeof req.body === "string") return parseJson(req.body);
|
|
70
76
|
if (Buffer.isBuffer(req.body)) return parseJson(req.body.toString("utf8"));
|
|
71
77
|
if (req.body !== void 0 && !isSkippedBody(req)) return req.body;
|
|
78
|
+
if (req.readableEnded) return void 0;
|
|
72
79
|
if (Number(req.headers["content-length"]) > MAX_BODY_BYTES) return TOO_LARGE;
|
|
73
80
|
const text = await readText(req);
|
|
74
81
|
return text === TOO_LARGE ? TOO_LARGE : parseJson(text);
|
|
@@ -79,10 +86,23 @@ var send = (res, status, body) => {
|
|
|
79
86
|
res.setHeader("Cache-Control", "no-store");
|
|
80
87
|
res.end(body);
|
|
81
88
|
};
|
|
82
|
-
var
|
|
89
|
+
var getResolvers = (scope, options) => {
|
|
90
|
+
switch (scope) {
|
|
91
|
+
case "admin:*": return [options.getCompanyId];
|
|
92
|
+
case "employee:*": return [options.getEmployeeId, options.getContractorId];
|
|
93
|
+
default: return;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var isAllowed = async (req, sub, resolvers) => {
|
|
97
|
+
return (await Promise.all(resolvers.filter((resolve) => resolve !== void 0).map((resolve) => resolve(req)))).includes(sub);
|
|
98
|
+
};
|
|
99
|
+
var sign = (options) => {
|
|
100
|
+
const { apiSecret, partnerId } = options;
|
|
83
101
|
if (!apiSecret) throw new Error("@nmbrco/server: `apiSecret` is required");
|
|
84
102
|
if (!partnerId) throw new Error("@nmbrco/server: `partnerId` is required");
|
|
85
|
-
|
|
103
|
+
const invalidResolver = RESOLVER_NAMES.find((name) => options[name] !== void 0 && typeof options[name] !== "function");
|
|
104
|
+
if (invalidResolver) throw new Error(`@nmbrco/server: \`${invalidResolver}\` must be a function`);
|
|
105
|
+
if (RESOLVER_NAMES.every((name) => options[name] === void 0)) throw new Error("@nmbrco/server: give `getCompanyId`, `getEmployeeId` or `getContractorId`");
|
|
86
106
|
const key = createSigningKey(apiSecret);
|
|
87
107
|
return async (req, res, next) => {
|
|
88
108
|
try {
|
|
@@ -90,6 +110,7 @@ var sign = ({ apiSecret, partnerId, authorize }) => {
|
|
|
90
110
|
res.setHeader("Allow", "POST");
|
|
91
111
|
return send(res, 405, "Method Not Allowed");
|
|
92
112
|
}
|
|
113
|
+
if (!isJson(req)) return send(res, 415, "Unsupported Media Type");
|
|
93
114
|
const body = await readBody(req);
|
|
94
115
|
if (body === TOO_LARGE) {
|
|
95
116
|
res.once("finish", () => {
|
|
@@ -97,12 +118,15 @@ var sign = ({ apiSecret, partnerId, authorize }) => {
|
|
|
97
118
|
});
|
|
98
119
|
return send(res, 413, "Payload Too Large");
|
|
99
120
|
}
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
send(res,
|
|
121
|
+
const message = parseSignRequest(body);
|
|
122
|
+
if (!message) return send(res, 400, "Invalid signing request");
|
|
123
|
+
const resolvers = getResolvers(message.scope, options);
|
|
124
|
+
if (!resolvers) return send(res, 400, "Unknown scope");
|
|
125
|
+
if (!await isAllowed(req, message.sub, resolvers)) return send(res, 403, "Forbidden");
|
|
126
|
+
send(res, 201, await signToken(partnerId, message, key));
|
|
104
127
|
} catch (error) {
|
|
105
128
|
if (next) return next(error);
|
|
129
|
+
console.error("@nmbrco/server: sign failed", error);
|
|
106
130
|
send(res, 500, "Internal Server Error");
|
|
107
131
|
}
|
|
108
132
|
};
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/sign-request.ts","../src/token.ts","../src/sign.ts"],"sourcesContent":["const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];\n\nexport type SignRequest = {\n companyId: string;\n method: string;\n uri: string;\n scope: string;\n hash?: string;\n};\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === 'string' && value.length > 0;\n\n// The browser can choose only these claims. The server sets `iss` and `exp`.\n// `authorize` decides if the signed-in user can have the requested `scope`.\nexport const parseSignRequest = (body: unknown): SignRequest | undefined => {\n if (typeof body !== 'object' || body === null) return undefined;\n\n const { sub, method, uri, scope, hash } = body as Record<string, unknown>;\n\n if (\n !isNonEmptyString(sub) ||\n !isNonEmptyString(method) ||\n !METHODS.includes(method) ||\n !isNonEmptyString(uri) ||\n !uri.startsWith('/') ||\n uri.startsWith('//') ||\n !isNonEmptyString(scope) ||\n (hash !== undefined && typeof hash !== 'string')\n ) {\n return undefined;\n }\n\n return { companyId: sub, method, uri, scope, ...(hash && { hash }) };\n};\n","import type { SignRequest } from './sign-request.js';\n\nimport { createHash } from 'node:crypto';\n\nimport { SignJWT } from 'jose';\n\nconst TOKEN_TTL = '5m';\n\n// The API stores the SHA-256 hex digest of the API secret, not the secret.\n// The API uses that digest as the HS256 key.\nexport const createSigningKey = (apiSecret: string) =>\n new TextEncoder().encode(\n createHash('sha256').update(apiSecret).digest('hex')\n );\n\nexport const signToken = (\n partnerId: string,\n { companyId, method, uri, scope, hash }: SignRequest,\n key: Uint8Array\n) =>\n new SignJWT({\n uri,\n method,\n scope,\n ...(hash && { hash }),\n })\n .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })\n .setIssuer(partnerId)\n .setSubject(companyId)\n .setExpirationTime(TOKEN_TTL)\n .sign(key);\n","import type { SignRequest } from './sign-request.js';\nimport type { IncomingMessage, ServerResponse } from 'node:http';\n\nimport { Buffer } from 'node:buffer';\n\nimport { parseSignRequest } from './sign-request.js';\nimport { createSigningKey, signToken } from './token.js';\n\nexport type SignOptions<Req extends IncomingMessage = IncomingMessage> = {\n apiSecret: string;\n partnerId: string;\n // The API checks that the company belongs to the partner.\n // It does not check that the signed-in user can access the company.\n authorize: (req: Req, request: SignRequest) => boolean | Promise<boolean>;\n};\n\nconst MAX_BODY_BYTES = 8 * 1024;\n\nconst LINGER_MS = 1000;\n\nconst TOO_LARGE = Symbol('TOO_LARGE');\n\nconst parseJson = (text: string): unknown => {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n};\n\n// Pause the stream at the limit. A destroyed stream also closes the socket,\n// and then the client gets no 413 response.\nconst readText = (req: IncomingMessage) =>\n new Promise<string | typeof TOO_LARGE>((resolve, reject) => {\n const chunks: Array<Buffer> = [];\n let size = 0;\n\n const onData = (chunk: Buffer) => {\n size += chunk.length;\n\n if (size > MAX_BODY_BYTES) {\n req.off('data', onData);\n req.pause();\n resolve(TOO_LARGE);\n return;\n }\n\n chunks.push(chunk);\n };\n\n req.on('data', onData);\n req.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));\n req.once('error', reject);\n });\n\n// Express 4 body parsers set `req.body` to `{}` when they skip a request.\n// Then the stream is not read yet, and `readBody` must read it.\nconst isSkippedBody = (req: IncomingMessage & { body?: unknown }) =>\n !req.readableEnded &&\n typeof req.body === 'object' &&\n req.body !== null &&\n Object.keys(req.body).length === 0;\n\n// Use `req.body` when a parser such as `express.json()` read the stream before.\nconst readBody = async (req: IncomingMessage & { body?: unknown }) => {\n if (typeof req.body === 'string') return parseJson(req.body);\n if (Buffer.isBuffer(req.body)) return parseJson(req.body.toString('utf8'));\n if (req.body !== undefined && !isSkippedBody(req)) return req.body;\n\n if (Number(req.headers['content-length']) > MAX_BODY_BYTES) return TOO_LARGE;\n\n const text = await readText(req);\n\n return text === TOO_LARGE ? TOO_LARGE : parseJson(text);\n};\n\nconst send = (res: ServerResponse, status: number, body: string) => {\n res.statusCode = status;\n res.setHeader('Content-Type', 'text/plain; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.end(body);\n};\n\nexport const sign = <Req extends IncomingMessage = IncomingMessage>({\n apiSecret,\n partnerId,\n authorize,\n}: SignOptions<Req>) => {\n if (!apiSecret) throw new Error('@nmbrco/server: `apiSecret` is required');\n if (!partnerId) throw new Error('@nmbrco/server: `partnerId` is required');\n if (typeof authorize !== 'function') {\n throw new Error('@nmbrco/server: `authorize` is required');\n }\n\n const key = createSigningKey(apiSecret);\n\n return async (\n req: Req & { body?: unknown },\n res: ServerResponse,\n next?: (error?: unknown) => void\n ) => {\n try {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST');\n return send(res, 405, 'Method Not Allowed');\n }\n\n const body = await readBody(req);\n\n // Close the connection a short time after the 413, so the client cannot\n // send the rest of the body. Node discards the data until then. Do not\n // close at once: a close with unread data resets the connection, and then\n // the client loses the 413 response.\n if (body === TOO_LARGE) {\n res.once('finish', () => {\n setTimeout(() => req.destroy(), LINGER_MS).unref();\n });\n return send(res, 413, 'Payload Too Large');\n }\n\n const request = parseSignRequest(body);\n\n if (!request) return send(res, 400, 'Invalid signing request');\n\n if (!(await authorize(req, request))) {\n return send(res, 403, 'Forbidden');\n }\n\n send(res, 201, await signToken(partnerId, request, key));\n } catch (error) {\n if (next) return next(error);\n\n send(res, 500, 'Internal Server Error');\n }\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/sign-request.ts","../src/token.ts","../src/sign.ts"],"sourcesContent":["const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];\n\nexport type SignRequest = {\n sub: string;\n method: string;\n uri: string;\n scope: string;\n hash?: string;\n};\n\nconst isNonEmptyString = (value: unknown): value is string =>\n typeof value === 'string' && value.length > 0;\n\n// The browser can choose only these claims. The server sets `iss` and `exp`.\n// `sign` checks the `scope` and the `sub` against the session of the user.\nexport const parseSignRequest = (body: unknown): SignRequest | undefined => {\n if (typeof body !== 'object' || body === null) return undefined;\n\n const { sub, method, uri, scope, hash } = body as Record<string, unknown>;\n\n if (\n !isNonEmptyString(sub) ||\n !isNonEmptyString(method) ||\n !METHODS.includes(method) ||\n !isNonEmptyString(uri) ||\n !uri.startsWith('/') ||\n uri.startsWith('//') ||\n !isNonEmptyString(scope) ||\n (hash !== undefined && !isNonEmptyString(hash))\n ) {\n return undefined;\n }\n\n return { sub, method, uri, scope, hash };\n};\n","import type { SignRequest } from './sign-request.js';\n\nimport { createHash } from 'node:crypto';\n\nimport { SignJWT } from 'jose';\n\nconst TOKEN_TTL = '5m';\n\n// The API stores the SHA-256 hex digest of the API secret, not the secret.\n// The API uses that digest as the HS256 key.\nexport const createSigningKey = (apiSecret: string) =>\n new TextEncoder().encode(\n createHash('sha256').update(apiSecret).digest('hex')\n );\n\nexport const signToken = (\n partnerId: string,\n { sub, method, uri, scope, hash }: SignRequest,\n key: Uint8Array\n) =>\n new SignJWT({\n uri,\n method,\n scope,\n hash,\n })\n .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })\n .setIssuer(partnerId)\n .setSubject(sub)\n .setExpirationTime(TOKEN_TTL)\n .sign(key);\n","import type { IncomingMessage, ServerResponse } from 'node:http';\n\nimport { Buffer } from 'node:buffer';\n\nimport { parseSignRequest } from './sign-request.js';\nimport { createSigningKey, signToken } from './token.js';\n\ntype SubjectId = string | null | undefined;\n\n// Returns the Nmbr id that the signed-in user can act on, from the session of\n// the partner, or nothing when the user cannot act on such an id. It must not\n// throw for a user with no such id: `sign` runs both employee resolvers.\nexport type SubjectResolver<Req extends IncomingMessage = IncomingMessage> = (\n req: Req\n) => SubjectId | Promise<SubjectId>;\n\n// The API checks that the subject belongs to the partner. It does not check\n// that the signed-in user can access the subject. The resolvers do that.\n// Give at least one resolver, and only the ones the app needs. A scope with no\n// resolver is off.\nexport type SignOptions<Req extends IncomingMessage = IncomingMessage> = {\n apiSecret: string;\n partnerId: string;\n // admin:* gives full payroll access to the company. Return the company id\n // only when the signed-in user is a payroll admin of that company.\n getCompanyId?: SubjectResolver<Req>;\n getEmployeeId?: SubjectResolver<Req>;\n getContractorId?: SubjectResolver<Req>;\n};\n\nconst RESOLVER_NAMES = [\n 'getCompanyId',\n 'getEmployeeId',\n 'getContractorId',\n] as const;\n\nconst MAX_BODY_BYTES = 8 * 1024;\n\nconst LINGER_MS = 1000;\n\nconst TOO_LARGE = Symbol('TOO_LARGE');\n\n// A cross-site HTML form can send a `text/plain` body that parses as JSON,\n// with the cookies of the user. Accept only JSON, which a form cannot send.\nconst isJson = (req: IncomingMessage) =>\n req.headers['content-type']?.split(';')[0].trim().toLowerCase() ===\n 'application/json';\n\nconst parseJson = (text: string): unknown => {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n};\n\n// Pause the stream at the limit. A destroyed stream also closes the socket,\n// and then the client gets no 413 response.\nconst readText = (req: IncomingMessage) =>\n new Promise<string | typeof TOO_LARGE>((resolve, reject) => {\n const chunks: Array<Buffer> = [];\n let size = 0;\n\n const onData = (chunk: Buffer) => {\n size += chunk.length;\n\n if (size > MAX_BODY_BYTES) {\n req.off('data', onData);\n req.pause();\n resolve(TOO_LARGE);\n return;\n }\n\n chunks.push(chunk);\n };\n\n req.on('data', onData);\n req.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));\n req.once('error', reject);\n });\n\n// Express 4 body parsers set `req.body` to `{}` when they skip a request.\n// Then the stream is not read yet, and `readBody` must read it.\nconst isSkippedBody = (req: IncomingMessage & { body?: unknown }) =>\n !req.readableEnded &&\n typeof req.body === 'object' &&\n req.body !== null &&\n Object.keys(req.body).length === 0;\n\n// Use `req.body` when a parser such as `express.json()` read the stream before.\nconst readBody = async (req: IncomingMessage & { body?: unknown }) => {\n if (typeof req.body === 'string') return parseJson(req.body);\n if (Buffer.isBuffer(req.body)) return parseJson(req.body.toString('utf8'));\n if (req.body !== undefined && !isSkippedBody(req)) return req.body;\n\n // An earlier middleware read the stream and did not set `req.body`. The\n // stream sends no more events, so `readText` would wait until the timeout.\n if (req.readableEnded) return undefined;\n\n if (Number(req.headers['content-length']) > MAX_BODY_BYTES) return TOO_LARGE;\n\n const text = await readText(req);\n\n return text === TOO_LARGE ? TOO_LARGE : parseJson(text);\n};\n\nconst send = (res: ServerResponse, status: number, body: string) => {\n res.statusCode = status;\n res.setHeader('Content-Type', 'text/plain; charset=utf-8');\n res.setHeader('Cache-Control', 'no-store');\n res.end(body);\n};\n\n// Add a scope here only when the API supports it. The Component sends an\n// employee:* request for an employee or a contractor, so the `sub` can match\n// the employee id or the contractor id. A person can be both in one company.\nconst getResolvers = <Req extends IncomingMessage>(\n scope: string,\n options: SignOptions<Req>\n) => {\n switch (scope) {\n case 'admin:*':\n return [options.getCompanyId];\n case 'employee:*':\n return [options.getEmployeeId, options.getContractorId];\n default:\n return undefined;\n }\n};\n\nconst isAllowed = async <Req extends IncomingMessage>(\n req: Req,\n sub: string,\n resolvers: Array<SubjectResolver<Req> | undefined>\n) => {\n const ids = await Promise.all(\n resolvers\n .filter((resolve) => resolve !== undefined)\n .map((resolve) => resolve(req))\n );\n\n return ids.includes(sub);\n};\n\nexport const sign = <Req extends IncomingMessage = IncomingMessage>(\n options: SignOptions<Req>\n) => {\n const { apiSecret, partnerId } = options;\n\n if (!apiSecret) throw new Error('@nmbrco/server: `apiSecret` is required');\n if (!partnerId) throw new Error('@nmbrco/server: `partnerId` is required');\n\n const invalidResolver = RESOLVER_NAMES.find(\n (name) => options[name] !== undefined && typeof options[name] !== 'function'\n );\n\n if (invalidResolver) {\n throw new Error(\n `@nmbrco/server: \\`${invalidResolver}\\` must be a function`\n );\n }\n\n if (RESOLVER_NAMES.every((name) => options[name] === undefined)) {\n throw new Error(\n '@nmbrco/server: give `getCompanyId`, `getEmployeeId` or `getContractorId`'\n );\n }\n\n const key = createSigningKey(apiSecret);\n\n return async (\n req: Req & { body?: unknown },\n res: ServerResponse,\n next?: (error?: unknown) => void\n ) => {\n try {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST');\n return send(res, 405, 'Method Not Allowed');\n }\n\n if (!isJson(req)) return send(res, 415, 'Unsupported Media Type');\n\n const body = await readBody(req);\n\n // Close the connection a short time after the 413, so the client cannot\n // send the rest of the body. Node discards the data until then. Do not\n // close at once: a close with unread data resets the connection, and then\n // the client loses the 413 response.\n if (body === TOO_LARGE) {\n res.once('finish', () => {\n setTimeout(() => req.destroy(), LINGER_MS).unref();\n });\n return send(res, 413, 'Payload Too Large');\n }\n\n const message = parseSignRequest(body);\n\n if (!message) return send(res, 400, 'Invalid signing request');\n\n // A component that is newer than this package can send a scope that\n // this package does not know. A 403 would hide that cause.\n const resolvers = getResolvers(message.scope, options);\n\n if (!resolvers) return send(res, 400, 'Unknown scope');\n\n if (!(await isAllowed(req, message.sub, resolvers))) {\n return send(res, 403, 'Forbidden');\n }\n\n send(res, 201, await signToken(partnerId, message, key));\n } catch (error) {\n if (next) return next(error);\n\n console.error('@nmbrco/server: sign failed', error);\n send(res, 500, 'Internal Server Error');\n }\n };\n};\n"],"mappings":"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"}
|
package/dist/sign-request.d.ts
CHANGED
package/dist/sign.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { SignRequest } from './sign-request.js';
|
|
2
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
type SubjectId = string | null | undefined;
|
|
3
|
+
export type SubjectResolver<Req extends IncomingMessage = IncomingMessage> = (req: Req) => SubjectId | Promise<SubjectId>;
|
|
3
4
|
export type SignOptions<Req extends IncomingMessage = IncomingMessage> = {
|
|
4
5
|
apiSecret: string;
|
|
5
6
|
partnerId: string;
|
|
6
|
-
|
|
7
|
+
getCompanyId?: SubjectResolver<Req>;
|
|
8
|
+
getEmployeeId?: SubjectResolver<Req>;
|
|
9
|
+
getContractorId?: SubjectResolver<Req>;
|
|
7
10
|
};
|
|
8
|
-
export declare const sign: <Req extends IncomingMessage = IncomingMessage>(
|
|
11
|
+
export declare const sign: <Req extends IncomingMessage = IncomingMessage>(options: SignOptions<Req>) => (req: Req & {
|
|
9
12
|
body?: unknown;
|
|
10
13
|
}, res: ServerResponse, next?: (error?: unknown) => void) => Promise<void>;
|
|
14
|
+
export {};
|
package/dist/token.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { SignRequest } from './sign-request.js';
|
|
2
2
|
export declare const createSigningKey: (apiSecret: string) => NodeJS.NonSharedUint8Array;
|
|
3
|
-
export declare const signToken: (partnerId: string, {
|
|
3
|
+
export declare const signToken: (partnerId: string, { sub, method, uri, scope, hash }: SignRequest, key: Uint8Array) => Promise<string>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nmbrco/server",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Server middleware that signs Nmbr Components requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "The Nmbr Company Ltd. (https://nmbr.co)",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "vite build",
|
|
16
|
+
"prepublishOnly": "vite build",
|
|
16
17
|
"type-check": "tsc --noEmit",
|
|
17
18
|
"test": "vitest run",
|
|
18
19
|
"test:watch": "vitest",
|