@forestadmin/agent-bff 1.2.0 → 1.4.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/README.md +53 -5
- package/dist/agent/agent-stub.d.ts +3 -0
- package/dist/agent/agent-stub.js +19 -0
- package/dist/agent/build-agent-query.d.ts +8 -0
- package/dist/agent/build-agent-query.js +7 -0
- package/dist/api-key/agent-token.d.ts +8 -0
- package/dist/api-key/agent-token.js +34 -0
- package/dist/api-key/api-key-authenticator.d.ts +17 -0
- package/dist/api-key/api-key-authenticator.js +61 -0
- package/dist/api-key/api-key-client.d.ts +37 -0
- package/dist/api-key/api-key-client.js +85 -0
- package/dist/api-key/api-key-error.d.ts +20 -0
- package/dist/api-key/api-key-error.js +34 -0
- package/dist/api-key/api-key-middleware.d.ts +10 -0
- package/dist/api-key/api-key-middleware.js +47 -0
- package/dist/api-key/api-key-resolve-error.d.ts +16 -0
- package/dist/api-key/api-key-resolve-error.js +16 -0
- package/dist/api-key/api-key.d.ts +8 -0
- package/dist/api-key/api-key.js +23 -0
- package/dist/api-key/resolve-cache.d.ts +17 -0
- package/dist/api-key/resolve-cache.js +56 -0
- package/dist/auth/auth-mode-middleware.d.ts +7 -0
- package/dist/auth/auth-mode-middleware.js +53 -0
- package/dist/auth/auth-mode.d.ts +7 -0
- package/dist/auth/auth-mode.js +25 -0
- package/dist/cli-core.js +90 -3
- package/dist/config/env-config.d.ts +3 -0
- package/dist/config/env-config.js +15 -1
- package/dist/cors/cors-middleware.d.ts +9 -0
- package/dist/cors/cors-middleware.js +29 -0
- package/dist/cors/origin.d.ts +7 -0
- package/dist/cors/origin.js +47 -0
- package/dist/cors/per-key-origin.d.ts +3 -0
- package/dist/cors/per-key-origin.js +16 -0
- package/dist/http/bff-http-error.d.ts +34 -0
- package/dist/http/bff-http-error.js +55 -0
- package/dist/http/error-middleware.d.ts +7 -0
- package/dist/http/error-middleware.js +49 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +26 -2
- package/dist/timezone/timezone-middleware.d.ts +7 -0
- package/dist/timezone/timezone-middleware.js +24 -0
- package/dist/timezone/timezone.d.ts +8 -0
- package/dist/timezone/timezone.js +35 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Standalone REST BFF (Backend-For-Frontend) that lets a trusted third-party UI call a Forest Admin
|
|
4
4
|
agent from a browser without learning MCP or JSON:API.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
It is a bootable Koa 3 server with a `/health` endpoint, a version header, env-driven config
|
|
7
|
+
validation, OAuth (Mode 1) + API-key (Mode 2) auth, and a hardened request edge (timezone, CORS,
|
|
8
|
+
auth-mode precedence, structured error contract). The data-endpoint proxy and OpenAPI generation
|
|
9
|
+
land in later slices.
|
|
9
10
|
|
|
10
11
|
## Usage
|
|
11
12
|
|
|
@@ -34,14 +35,61 @@ yarn start:dev # node --env-file=.env dist/cli.js
|
|
|
34
35
|
| `FOREST_SERVER_URL` | yes | Forest SaaS API base URL. |
|
|
35
36
|
| `FOREST_APP_URL` | yes | Forest front base URL (OAuth front-channel, later slices). |
|
|
36
37
|
| `AGENT_URL` | yes | The customer agent base URL the BFF calls via agent-client. |
|
|
38
|
+
| `BFF_TOKEN_ENCRYPTION_KEY`| for OAuth | Base64-encoded 32-byte AES-256 key encrypting stored refresh tokens. Until it is set, the `/oauth/*` token-issuance routes are disabled and `/health` reports `degraded`; already-issued `bff_access` tokens still authenticate on `/agent/*` whenever `FOREST_AUTH_SECRET` is present. |
|
|
37
39
|
| `HTTP_PORT` | no | Server port, integer 0–65535. Defaults to `3450`. `0` binds an OS-assigned ephemeral port. |
|
|
40
|
+
| `BFF_ALLOWED_ORIGINS`| no | Comma-separated CORS allow-list of exact origins (scheme + host + port). No wildcard. Empty ⇒ no cross-origin browser access. |
|
|
41
|
+
| `BFF_DEFAULT_TIMEZONE`| no | Fallback IANA timezone used when a request carries neither an `X-Forest-Timezone` header nor a body `timezone`. |
|
|
38
42
|
|
|
39
43
|
### Config validation
|
|
40
44
|
|
|
41
|
-
- A malformed value (a non-http(s) `*_URL`, a `HTTP_PORT` that is not a decimal integer in 0–65535
|
|
42
|
-
fails fast at boot: the process exits with a clear error and
|
|
45
|
+
- A malformed value (a non-http(s) `*_URL`, a `HTTP_PORT` that is not a decimal integer in 0–65535,
|
|
46
|
+
a non-IANA `BFF_DEFAULT_TIMEZONE`) fails fast at boot: the process exits with a clear error and
|
|
47
|
+
never echoes the offending value.
|
|
43
48
|
- A required var that is absent (or empty / whitespace-only) does not crash the server. It boots and
|
|
44
49
|
reports the gap through `/health` (503 `degraded`).
|
|
50
|
+
- Malformed `BFF_ALLOWED_ORIGINS` entries (including a literal `*`) are dropped and logged once at
|
|
51
|
+
boot (`Warn`); they never enter the allow-list, so a wildcard origin can never be served.
|
|
52
|
+
|
|
53
|
+
## Request edge (`/agent/*`)
|
|
54
|
+
|
|
55
|
+
Every agent call flows through a request edge that enforces three cross-cutting concerns before the
|
|
56
|
+
(Slice-3) proxy runs. Errors use a structured, type-first contract — `{ error: { type, status,
|
|
57
|
+
message, details? } }` — so consumers branch on `error.type`, never on message text.
|
|
58
|
+
|
|
59
|
+
### Auth-mode precedence
|
|
60
|
+
|
|
61
|
+
| Presented credentials | Result |
|
|
62
|
+
| ---------------------------------------------- | ------------------------------- |
|
|
63
|
+
| `Authorization: Bearer <bff_access>` | Mode 1 (OAuth session) |
|
|
64
|
+
| `X-Forest-Bff-Key` | Mode 2 (API key) |
|
|
65
|
+
| both | `400 ambiguous_credentials` |
|
|
66
|
+
| neither | `401 unauthorized` |
|
|
67
|
+
|
|
68
|
+
A Mode 1 `bff_access` that is malformed or wrong-typed → `401 unauthorized`; one that is validly
|
|
69
|
+
signed but expired → `401 session_expired` (the client should refresh). Refresh-token reuse remains
|
|
70
|
+
a `POST /oauth/token` concern (`session_invalidated`, OAuth/RFC shape).
|
|
71
|
+
|
|
72
|
+
### CORS
|
|
73
|
+
|
|
74
|
+
Two layers, both driven by exact-origin matching (case-insensitive scheme/host, default ports
|
|
75
|
+
normalized away, no trailing slash, no wildcard, no subdomain matching):
|
|
76
|
+
|
|
77
|
+
- **Layer 1 (transport)** — the only layer that sets `Access-Control-Allow-Origin`. An origin in
|
|
78
|
+
`BFF_ALLOWED_ORIGINS` is echoed back exactly; anything else gets no CORS headers (the browser
|
|
79
|
+
blocks). Applies to `POST /oauth/token` too. Preflight `OPTIONS` from an allow-listed origin gets
|
|
80
|
+
the allowed methods + headers; credentials are never enabled.
|
|
81
|
+
- **Layer 2 (per-key authorization, Mode 2 only)** — when the resolved key has a non-empty
|
|
82
|
+
`allowedOrigins`, the request `Origin` must also be in that list (a missing `Origin` is rejected),
|
|
83
|
+
else `403 origin_not_allowed`. An empty per-key list is a no-op.
|
|
84
|
+
|
|
85
|
+
**Local development:** browsers still enforce CORS against `localhost`, so add your dev origin(s) to
|
|
86
|
+
`BFF_ALLOWED_ORIGINS` (e.g. `BFF_ALLOWED_ORIGINS=http://localhost:4200`) — there is no dev bypass.
|
|
87
|
+
|
|
88
|
+
### Timezone
|
|
89
|
+
|
|
90
|
+
The BFF always forwards an explicit `timezone` to the agent, resolved in order: (1) `X-Forest-Timezone`
|
|
91
|
+
header, (2) body `timezone` field, (3) `BFF_DEFAULT_TIMEZONE`. None → `400 missing_timezone`; a
|
|
92
|
+
non-IANA value → `400 invalid_timezone`.
|
|
45
93
|
|
|
46
94
|
## Sessions & token rotation
|
|
47
95
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = createAgentStubMiddleware;
|
|
4
|
+
const build_agent_query_1 = require("./build-agent-query");
|
|
5
|
+
function createAgentStubMiddleware() {
|
|
6
|
+
return async function agentStubMiddleware(ctx) {
|
|
7
|
+
const query = (0, build_agent_query_1.buildAgentQuery)({ timezone: ctx.state.timezone });
|
|
8
|
+
ctx.state.agentQuery = query;
|
|
9
|
+
ctx.status = 501;
|
|
10
|
+
ctx.body = {
|
|
11
|
+
error: {
|
|
12
|
+
type: 'not_implemented',
|
|
13
|
+
status: 501,
|
|
14
|
+
message: 'Agent proxy is not implemented yet',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYWdlbnQtc3R1Yi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hZ2VudC9hZ2VudC1zdHViLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBSUEsNENBY0M7QUFoQkQsMkRBQXNEO0FBRXRELFNBQXdCLHlCQUF5QjtJQUMvQyxPQUFPLEtBQUssVUFBVSxtQkFBbUIsQ0FBQyxHQUFHO1FBQzNDLE1BQU0sS0FBSyxHQUFHLElBQUEsbUNBQWUsRUFBQyxFQUFFLFFBQVEsRUFBRSxHQUFHLENBQUMsS0FBSyxDQUFDLFFBQWtCLEVBQUUsQ0FBQyxDQUFDO1FBQzFFLEdBQUcsQ0FBQyxLQUFLLENBQUMsVUFBVSxHQUFHLEtBQUssQ0FBQztRQUU3QixHQUFHLENBQUMsTUFBTSxHQUFHLEdBQUcsQ0FBQztRQUNqQixHQUFHLENBQUMsSUFBSSxHQUFHO1lBQ1QsS0FBSyxFQUFFO2dCQUNMLElBQUksRUFBRSxpQkFBaUI7Z0JBQ3ZCLE1BQU0sRUFBRSxHQUFHO2dCQUNYLE9BQU8sRUFBRSxvQ0FBb0M7YUFDOUM7U0FDRixDQUFDO0lBQ0osQ0FBQyxDQUFDO0FBQ0osQ0FBQyJ9
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildAgentQuery = buildAgentQuery;
|
|
4
|
+
function buildAgentQuery({ timezone }) {
|
|
5
|
+
return { timezone };
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVpbGQtYWdlbnQtcXVlcnkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYWdlbnQvYnVpbGQtYWdlbnQtcXVlcnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7QUFRQSwwQ0FFQztBQUZELFNBQWdCLGVBQWUsQ0FBQyxFQUFFLFFBQVEsRUFBeUI7SUFDakUsT0FBTyxFQUFFLFFBQVEsRUFBRSxDQUFDO0FBQ3RCLENBQUMifQ==
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedApiKeyIdentity } from './api-key-client';
|
|
2
|
+
export declare const AGENT_TOKEN_EXPIRES_IN = "5m";
|
|
3
|
+
export interface IssueAgentTokenParams {
|
|
4
|
+
identity: ResolvedApiKeyIdentity;
|
|
5
|
+
authSecret: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function issueAgentToken({ identity, authSecret }: IssueAgentTokenParams): string;
|
|
8
|
+
//# sourceMappingURL=agent-token.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AGENT_TOKEN_EXPIRES_IN = void 0;
|
|
7
|
+
exports.issueAgentToken = issueAgentToken;
|
|
8
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
|
+
exports.AGENT_TOKEN_EXPIRES_IN = '5m';
|
|
10
|
+
function tagsToRecord(tags) {
|
|
11
|
+
return tags.reduce((memo, { key, value }) => ({ ...memo, [key]: value }), {});
|
|
12
|
+
}
|
|
13
|
+
function issueAgentToken({ identity, authSecret }) {
|
|
14
|
+
const { user, renderingId } = identity;
|
|
15
|
+
const firstName = user.firstName ?? '';
|
|
16
|
+
const lastName = user.lastName ?? '';
|
|
17
|
+
const tags = tagsToRecord(user.tags);
|
|
18
|
+
// snake_case aliases: Ruby/Python agents splat JWT claims into Caller (snake_case kwargs).
|
|
19
|
+
return jsonwebtoken_1.default.sign({
|
|
20
|
+
id: user.id,
|
|
21
|
+
email: user.email,
|
|
22
|
+
firstName,
|
|
23
|
+
lastName,
|
|
24
|
+
team: user.team,
|
|
25
|
+
renderingId,
|
|
26
|
+
tags,
|
|
27
|
+
permissionLevel: user.permissionLevel,
|
|
28
|
+
first_name: firstName,
|
|
29
|
+
last_name: lastName,
|
|
30
|
+
rendering_id: renderingId,
|
|
31
|
+
permission_level: user.permissionLevel,
|
|
32
|
+
}, authSecret, { algorithm: 'HS256', expiresIn: exports.AGENT_TOKEN_EXPIRES_IN });
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYWdlbnQtdG9rZW4uanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYXBpLWtleS9hZ2VudC10b2tlbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFlQSwwQ0F5QkM7QUF0Q0QsZ0VBQXdDO0FBRTNCLFFBQUEsc0JBQXNCLEdBQUcsSUFBSSxDQUFDO0FBTzNDLFNBQVMsWUFBWSxDQUFDLElBQXNDO0lBQzFELE9BQU8sSUFBSSxDQUFDLE1BQU0sQ0FBQyxDQUFDLElBQUksRUFBRSxFQUFFLEdBQUcsRUFBRSxLQUFLLEVBQUUsRUFBRSxFQUFFLENBQUMsQ0FBQyxFQUFFLEdBQUcsSUFBSSxFQUFFLENBQUMsR0FBRyxDQUFDLEVBQUUsS0FBSyxFQUFFLENBQUMsRUFBRSxFQUFFLENBQUMsQ0FBQztBQUNoRixDQUFDO0FBRUQsU0FBZ0IsZUFBZSxDQUFDLEVBQUUsUUFBUSxFQUFFLFVBQVUsRUFBeUI7SUFDN0UsTUFBTSxFQUFFLElBQUksRUFBRSxXQUFXLEVBQUUsR0FBRyxRQUFRLENBQUM7SUFDdkMsTUFBTSxTQUFTLEdBQUcsSUFBSSxDQUFDLFNBQVMsSUFBSSxFQUFFLENBQUM7SUFDdkMsTUFBTSxRQUFRLEdBQUcsSUFBSSxDQUFDLFFBQVEsSUFBSSxFQUFFLENBQUM7SUFDckMsTUFBTSxJQUFJLEdBQUcsWUFBWSxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUVyQywyRkFBMkY7SUFDM0YsT0FBTyxzQkFBWSxDQUFDLElBQUksQ0FDdEI7UUFDRSxFQUFFLEVBQUUsSUFBSSxDQUFDLEVBQUU7UUFDWCxLQUFLLEVBQUUsSUFBSSxDQUFDLEtBQUs7UUFDakIsU0FBUztRQUNULFFBQVE7UUFDUixJQUFJLEVBQUUsSUFBSSxDQUFDLElBQUk7UUFDZixXQUFXO1FBQ1gsSUFBSTtRQUNKLGVBQWUsRUFBRSxJQUFJLENBQUMsZUFBZTtRQUNyQyxVQUFVLEVBQUUsU0FBUztRQUNyQixTQUFTLEVBQUUsUUFBUTtRQUNuQixZQUFZLEVBQUUsV0FBVztRQUN6QixnQkFBZ0IsRUFBRSxJQUFJLENBQUMsZUFBZTtLQUN2QyxFQUNELFVBQVUsRUFDVixFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLDhCQUFzQixFQUFFLENBQzFELENBQUM7QUFDSixDQUFDIn0=
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type ApiKeyClient from './api-key-client';
|
|
2
|
+
import type { ResolvedApiKeyIdentity } from './api-key-client';
|
|
3
|
+
import type { ResolveCache } from './resolve-cache';
|
|
4
|
+
export interface ApiKeyAuthenticatorOptions {
|
|
5
|
+
client: ApiKeyClient;
|
|
6
|
+
cache: ResolveCache;
|
|
7
|
+
authSecret: string;
|
|
8
|
+
}
|
|
9
|
+
export interface AuthenticatedApiKey {
|
|
10
|
+
agentToken: string;
|
|
11
|
+
identity: ResolvedApiKeyIdentity;
|
|
12
|
+
}
|
|
13
|
+
export interface ApiKeyAuthenticator {
|
|
14
|
+
authenticate(rawKey: string): Promise<AuthenticatedApiKey>;
|
|
15
|
+
}
|
|
16
|
+
export default function createApiKeyAuthenticator({ client, cache, authSecret, }: ApiKeyAuthenticatorOptions): ApiKeyAuthenticator;
|
|
17
|
+
//# sourceMappingURL=api-key-authenticator.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = createApiKeyAuthenticator;
|
|
4
|
+
const agent_token_1 = require("./agent-token");
|
|
5
|
+
const api_key_1 = require("./api-key");
|
|
6
|
+
const api_key_client_1 = require("./api-key-client");
|
|
7
|
+
const api_key_error_1 = require("./api-key-error");
|
|
8
|
+
const UNAVAILABLE_RETRY_AFTER_SECONDS = 5;
|
|
9
|
+
const NEGATIVE_CACHE_STATUSES = new Set([401, 403]);
|
|
10
|
+
function mapResolveError(error) {
|
|
11
|
+
if (error.unreachable)
|
|
12
|
+
return (0, api_key_error_1.keyResolutionUnavailable)(UNAVAILABLE_RETRY_AFTER_SECONDS);
|
|
13
|
+
switch (error.status) {
|
|
14
|
+
case 401:
|
|
15
|
+
return (0, api_key_error_1.invalidApiKey)();
|
|
16
|
+
case 403:
|
|
17
|
+
return (0, api_key_error_1.forestIdentityNotAllowed)();
|
|
18
|
+
case 400:
|
|
19
|
+
return (0, api_key_error_1.invalidRequest)();
|
|
20
|
+
case 429:
|
|
21
|
+
return (0, api_key_error_1.keyResolutionUnavailable)(error.retryAfter ?? UNAVAILABLE_RETRY_AFTER_SECONDS);
|
|
22
|
+
default:
|
|
23
|
+
return (0, api_key_error_1.keyResolutionUnavailable)(UNAVAILABLE_RETRY_AFTER_SECONDS);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function createApiKeyAuthenticator({ client, cache, authSecret, }) {
|
|
27
|
+
function mint(identity) {
|
|
28
|
+
return { agentToken: (0, agent_token_1.issueAgentToken)({ identity, authSecret }), identity };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async authenticate(rawKey) {
|
|
32
|
+
const parsed = (0, api_key_1.parseApiKey)(rawKey);
|
|
33
|
+
if (!parsed)
|
|
34
|
+
throw (0, api_key_error_1.invalidApiKey)();
|
|
35
|
+
const hash = (0, api_key_1.hashApiKey)(parsed.keyId, parsed.secret);
|
|
36
|
+
const cachedIdentity = cache.getPositive(hash);
|
|
37
|
+
if (cachedIdentity)
|
|
38
|
+
return mint(cachedIdentity);
|
|
39
|
+
const cachedError = cache.getNegative(hash);
|
|
40
|
+
if (cachedError)
|
|
41
|
+
throw cachedError;
|
|
42
|
+
let identity;
|
|
43
|
+
try {
|
|
44
|
+
identity = await client.resolveApiKey(parsed);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof api_key_client_1.ApiKeyResolveError) {
|
|
48
|
+
const mapped = mapResolveError(error);
|
|
49
|
+
if (NEGATIVE_CACHE_STATUSES.has(mapped.status))
|
|
50
|
+
cache.setNegative(hash, mapped);
|
|
51
|
+
throw mapped;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const authenticated = mint(identity);
|
|
56
|
+
cache.setPositive(hash, identity);
|
|
57
|
+
return authenticated;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS1hdXRoZW50aWNhdG9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FwaS1rZXkvYXBpLWtleS1hdXRoZW50aWNhdG9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7O0FBa0RBLDRDQTRDQztBQXpGRCwrQ0FBZ0Q7QUFDaEQsdUNBQW9EO0FBQ3BELHFEQUFzRDtBQUN0RCxtREFLeUI7QUFFekIsTUFBTSwrQkFBK0IsR0FBRyxDQUFDLENBQUM7QUFDMUMsTUFBTSx1QkFBdUIsR0FBRyxJQUFJLEdBQUcsQ0FBQyxDQUFDLEdBQUcsRUFBRSxHQUFHLENBQUMsQ0FBQyxDQUFDO0FBaUJwRCxTQUFTLGVBQWUsQ0FBQyxLQUF5QjtJQUNoRCxJQUFJLEtBQUssQ0FBQyxXQUFXO1FBQUUsT0FBTyxJQUFBLHdDQUF3QixFQUFDLCtCQUErQixDQUFDLENBQUM7SUFFeEYsUUFBUSxLQUFLLENBQUMsTUFBTSxFQUFFLENBQUM7UUFDckIsS0FBSyxHQUFHO1lBQ04sT0FBTyxJQUFBLDZCQUFhLEdBQUUsQ0FBQztRQUN6QixLQUFLLEdBQUc7WUFDTixPQUFPLElBQUEsd0NBQXdCLEdBQUUsQ0FBQztRQUNwQyxLQUFLLEdBQUc7WUFDTixPQUFPLElBQUEsOEJBQWMsR0FBRSxDQUFDO1FBQzFCLEtBQUssR0FBRztZQUNOLE9BQU8sSUFBQSx3Q0FBd0IsRUFBQyxLQUFLLENBQUMsVUFBVSxJQUFJLCtCQUErQixDQUFDLENBQUM7UUFDdkY7WUFDRSxPQUFPLElBQUEsd0NBQXdCLEVBQUMsK0JBQStCLENBQUMsQ0FBQztJQUNyRSxDQUFDO0FBQ0gsQ0FBQztBQUVELFNBQXdCLHlCQUF5QixDQUFDLEVBQ2hELE1BQU0sRUFDTixLQUFLLEVBQ0wsVUFBVSxHQUNpQjtJQUMzQixTQUFTLElBQUksQ0FBQyxRQUFnQztRQUM1QyxPQUFPLEVBQUUsVUFBVSxFQUFFLElBQUEsNkJBQWUsRUFBQyxFQUFFLFFBQVEsRUFBRSxVQUFVLEVBQUUsQ0FBQyxFQUFFLFFBQVEsRUFBRSxDQUFDO0lBQzdFLENBQUM7SUFFRCxPQUFPO1FBQ0wsS0FBSyxDQUFDLFlBQVksQ0FBQyxNQUFNO1lBQ3ZCLE1BQU0sTUFBTSxHQUFHLElBQUEscUJBQVcsRUFBQyxNQUFNLENBQUMsQ0FBQztZQUVuQyxJQUFJLENBQUMsTUFBTTtnQkFBRSxNQUFNLElBQUEsNkJBQWEsR0FBRSxDQUFDO1lBRW5DLE1BQU0sSUFBSSxHQUFHLElBQUEsb0JBQVUsRUFBQyxNQUFNLENBQUMsS0FBSyxFQUFFLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQztZQUVyRCxNQUFNLGNBQWMsR0FBRyxLQUFLLENBQUMsV0FBVyxDQUFDLElBQUksQ0FBQyxDQUFDO1lBQy9DLElBQUksY0FBYztnQkFBRSxPQUFPLElBQUksQ0FBQyxjQUFjLENBQUMsQ0FBQztZQUVoRCxNQUFNLFdBQVcsR0FBRyxLQUFLLENBQUMsV0FBVyxDQUFDLElBQUksQ0FBQyxDQUFDO1lBQzVDLElBQUksV0FBVztnQkFBRSxNQUFNLFdBQVcsQ0FBQztZQUVuQyxJQUFJLFFBQWdDLENBQUM7WUFFckMsSUFBSSxDQUFDO2dCQUNILFFBQVEsR0FBRyxNQUFNLE1BQU0sQ0FBQyxhQUFhLENBQUMsTUFBTSxDQUFDLENBQUM7WUFDaEQsQ0FBQztZQUFDLE9BQU8sS0FBSyxFQUFFLENBQUM7Z0JBQ2YsSUFBSSxLQUFLLFlBQVksbUNBQWtCLEVBQUUsQ0FBQztvQkFDeEMsTUFBTSxNQUFNLEdBQUcsZUFBZSxDQUFDLEtBQUssQ0FBQyxDQUFDO29CQUN0QyxJQUFJLHVCQUF1QixDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDO3dCQUFFLEtBQUssQ0FBQyxXQUFXLENBQUMsSUFBSSxFQUFFLE1BQU0sQ0FBQyxDQUFDO29CQUVoRixNQUFNLE1BQU0sQ0FBQztnQkFDZixDQUFDO2dCQUVELE1BQU0sS0FBSyxDQUFDO1lBQ2QsQ0FBQztZQUVELE1BQU0sYUFBYSxHQUFHLElBQUksQ0FBQyxRQUFRLENBQUMsQ0FBQztZQUNyQyxLQUFLLENBQUMsV0FBVyxDQUFDLElBQUksRUFBRSxRQUFRLENBQUMsQ0FBQztZQUVsQyxPQUFPLGFBQWEsQ0FBQztRQUN2QixDQUFDO0tBQ0YsQ0FBQztBQUNKLENBQUMifQ==
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { ApiKeyResolveError } from './api-key-resolve-error';
|
|
2
|
+
export type { ApiKeyResolveErrorParams } from './api-key-resolve-error';
|
|
3
|
+
export interface ApiKeyIdentityUser {
|
|
4
|
+
id: number;
|
|
5
|
+
email: string;
|
|
6
|
+
firstName: string | null;
|
|
7
|
+
lastName: string | null;
|
|
8
|
+
team: string;
|
|
9
|
+
tags: {
|
|
10
|
+
key: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}[];
|
|
13
|
+
permissionLevel: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ResolvedApiKeyIdentity {
|
|
16
|
+
user: ApiKeyIdentityUser;
|
|
17
|
+
renderingId: number;
|
|
18
|
+
allowedOrigins: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface ApiKeyClientOptions {
|
|
21
|
+
forestServerUrl: string;
|
|
22
|
+
envSecret: string;
|
|
23
|
+
}
|
|
24
|
+
export default class ApiKeyClient {
|
|
25
|
+
private readonly forestServerUrl;
|
|
26
|
+
private readonly envSecret;
|
|
27
|
+
constructor({ forestServerUrl, envSecret }: ApiKeyClientOptions);
|
|
28
|
+
resolveApiKey(parsedKey: {
|
|
29
|
+
keyId: string;
|
|
30
|
+
secret: string;
|
|
31
|
+
}): Promise<ResolvedApiKeyIdentity>;
|
|
32
|
+
private static isResolvedIdentity;
|
|
33
|
+
private static isIdentityUser;
|
|
34
|
+
private static parseRetryAfter;
|
|
35
|
+
private url;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=api-key-client.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ApiKeyResolveError = void 0;
|
|
4
|
+
const api_key_resolve_error_1 = require("./api-key-resolve-error");
|
|
5
|
+
var api_key_resolve_error_2 = require("./api-key-resolve-error");
|
|
6
|
+
Object.defineProperty(exports, "ApiKeyResolveError", { enumerable: true, get: function () { return api_key_resolve_error_2.ApiKeyResolveError; } });
|
|
7
|
+
const DEFAULT_HEADERS = { 'Content-Type': 'application/json' };
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
9
|
+
const RESOLVE_PATH = '/liana/v1/bff-api-keys/resolve';
|
|
10
|
+
class ApiKeyClient {
|
|
11
|
+
constructor({ forestServerUrl, envSecret }) {
|
|
12
|
+
this.forestServerUrl = forestServerUrl;
|
|
13
|
+
this.envSecret = envSecret;
|
|
14
|
+
}
|
|
15
|
+
async resolveApiKey(parsedKey) {
|
|
16
|
+
let response;
|
|
17
|
+
try {
|
|
18
|
+
response = await fetch(this.url(RESOLVE_PATH), {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { ...DEFAULT_HEADERS, 'forest-secret-key': this.envSecret },
|
|
21
|
+
body: JSON.stringify({ keyId: parsedKey.keyId, secret: parsedKey.secret }),
|
|
22
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new api_key_resolve_error_1.ApiKeyResolveError({ unreachable: true });
|
|
27
|
+
}
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const body = (await response.json().catch(() => ({})));
|
|
30
|
+
const firstError = body.errors?.[0];
|
|
31
|
+
throw new api_key_resolve_error_1.ApiKeyResolveError({
|
|
32
|
+
status: response.status,
|
|
33
|
+
code: firstError?.meta?.code,
|
|
34
|
+
name: firstError?.name,
|
|
35
|
+
retryAfter: ApiKeyClient.parseRetryAfter(response.headers.get('retry-after')),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = await response.json();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new api_key_resolve_error_1.ApiKeyResolveError({ unreachable: true });
|
|
44
|
+
}
|
|
45
|
+
// A well-formed HTTP 200 with an incomplete body is an unusable resolution, not a caller
|
|
46
|
+
// error: surface it as unavailable rather than letting a later `user` deref throw a 500.
|
|
47
|
+
if (!ApiKeyClient.isResolvedIdentity(body)) {
|
|
48
|
+
throw new api_key_resolve_error_1.ApiKeyResolveError({ unreachable: true });
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
static isResolvedIdentity(body) {
|
|
53
|
+
if (typeof body !== 'object' || body === null)
|
|
54
|
+
return false;
|
|
55
|
+
const candidate = body;
|
|
56
|
+
return (typeof candidate.renderingId === 'number' &&
|
|
57
|
+
Array.isArray(candidate.allowedOrigins) &&
|
|
58
|
+
ApiKeyClient.isIdentityUser(candidate.user));
|
|
59
|
+
}
|
|
60
|
+
// Every field the agent-token mint reads must be present, so a partial body fails here
|
|
61
|
+
// (mapped to unavailable) instead of minting a token with undefined claims later.
|
|
62
|
+
static isIdentityUser(user) {
|
|
63
|
+
if (typeof user !== 'object' || user === null)
|
|
64
|
+
return false;
|
|
65
|
+
const candidate = user;
|
|
66
|
+
return (typeof candidate.id === 'number' &&
|
|
67
|
+
typeof candidate.email === 'string' &&
|
|
68
|
+
typeof candidate.team === 'string' &&
|
|
69
|
+
typeof candidate.permissionLevel === 'string' &&
|
|
70
|
+
Array.isArray(candidate.tags));
|
|
71
|
+
}
|
|
72
|
+
// Retry-After may be an HTTP-date rather than delay-seconds; keep only a positive integer so a
|
|
73
|
+
// date/negative/zero value falls back to the caller's default instead of a useless backoff hint.
|
|
74
|
+
static parseRetryAfter(header) {
|
|
75
|
+
if (header === null)
|
|
76
|
+
return undefined;
|
|
77
|
+
const seconds = Number(header);
|
|
78
|
+
return Number.isInteger(seconds) && seconds > 0 ? seconds : undefined;
|
|
79
|
+
}
|
|
80
|
+
url(path) {
|
|
81
|
+
return new URL(path, this.forestServerUrl).toString();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.default = ApiKeyClient;
|
|
85
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS1jbGllbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvYXBpLWtleS9hcGkta2V5LWNsaWVudC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSxtRUFBNkQ7QUFFN0QsaUVBQTZEO0FBQXBELDJIQUFBLGtCQUFrQixPQUFBO0FBd0IzQixNQUFNLGVBQWUsR0FBRyxFQUFFLGNBQWMsRUFBRSxrQkFBa0IsRUFBVyxDQUFDO0FBQ3hFLE1BQU0sa0JBQWtCLEdBQUcsS0FBTSxDQUFDO0FBQ2xDLE1BQU0sWUFBWSxHQUFHLGdDQUFnQyxDQUFDO0FBTXRELE1BQXFCLFlBQVk7SUFJL0IsWUFBWSxFQUFFLGVBQWUsRUFBRSxTQUFTLEVBQXVCO1FBQzdELElBQUksQ0FBQyxlQUFlLEdBQUcsZUFBZSxDQUFDO1FBQ3ZDLElBQUksQ0FBQyxTQUFTLEdBQUcsU0FBUyxDQUFDO0lBQzdCLENBQUM7SUFFRCxLQUFLLENBQUMsYUFBYSxDQUFDLFNBR25CO1FBQ0MsSUFBSSxRQUFrQixDQUFDO1FBRXZCLElBQUksQ0FBQztZQUNILFFBQVEsR0FBRyxNQUFNLEtBQUssQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLFlBQVksQ0FBQyxFQUFFO2dCQUM3QyxNQUFNLEVBQUUsTUFBTTtnQkFDZCxPQUFPLEVBQUUsRUFBRSxHQUFHLGVBQWUsRUFBRSxtQkFBbUIsRUFBRSxJQUFJLENBQUMsU0FBUyxFQUFFO2dCQUNwRSxJQUFJLEVBQUUsSUFBSSxDQUFDLFNBQVMsQ0FBQyxFQUFFLEtBQUssRUFBRSxTQUFTLENBQUMsS0FBSyxFQUFFLE1BQU0sRUFBRSxTQUFTLENBQUMsTUFBTSxFQUFFLENBQUM7Z0JBQzFFLE1BQU0sRUFBRSxXQUFXLENBQUMsT0FBTyxDQUFDLGtCQUFrQixDQUFDO2FBQ2hELENBQUMsQ0FBQztRQUNMLENBQUM7UUFBQyxNQUFNLENBQUM7WUFDUCxNQUFNLElBQUksMENBQWtCLENBQUMsRUFBRSxXQUFXLEVBQUUs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
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ApiKeyErrorType = 'invalid_api_key' | 'forest_identity_not_allowed' | 'invalid_request' | 'key_resolution_unavailable';
|
|
2
|
+
export declare class ApiKeyError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly type: ApiKeyErrorType;
|
|
5
|
+
readonly retryAfter?: number;
|
|
6
|
+
constructor(status: number, type: ApiKeyErrorType, message: string, retryAfter?: number);
|
|
7
|
+
}
|
|
8
|
+
export interface ApiKeyErrorBody {
|
|
9
|
+
error: {
|
|
10
|
+
type: ApiKeyErrorType;
|
|
11
|
+
status: number;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export declare function toErrorBody(error: ApiKeyError): ApiKeyErrorBody;
|
|
16
|
+
export declare function invalidApiKey(message?: string): ApiKeyError;
|
|
17
|
+
export declare function forestIdentityNotAllowed(message?: string): ApiKeyError;
|
|
18
|
+
export declare function invalidRequest(message?: string): ApiKeyError;
|
|
19
|
+
export declare function keyResolutionUnavailable(retryAfter: number, message?: string): ApiKeyError;
|
|
20
|
+
//# sourceMappingURL=api-key-error.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ApiKeyError = void 0;
|
|
4
|
+
exports.toErrorBody = toErrorBody;
|
|
5
|
+
exports.invalidApiKey = invalidApiKey;
|
|
6
|
+
exports.forestIdentityNotAllowed = forestIdentityNotAllowed;
|
|
7
|
+
exports.invalidRequest = invalidRequest;
|
|
8
|
+
exports.keyResolutionUnavailable = keyResolutionUnavailable;
|
|
9
|
+
class ApiKeyError extends Error {
|
|
10
|
+
constructor(status, type, message, retryAfter) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ApiKeyError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.type = type;
|
|
15
|
+
this.retryAfter = retryAfter;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.ApiKeyError = ApiKeyError;
|
|
19
|
+
function toErrorBody(error) {
|
|
20
|
+
return { error: { type: error.type, status: error.status, message: error.message } };
|
|
21
|
+
}
|
|
22
|
+
function invalidApiKey(message = 'Invalid API key') {
|
|
23
|
+
return new ApiKeyError(401, 'invalid_api_key', message);
|
|
24
|
+
}
|
|
25
|
+
function forestIdentityNotAllowed(message = 'Forest identity not allowed') {
|
|
26
|
+
return new ApiKeyError(403, 'forest_identity_not_allowed', message);
|
|
27
|
+
}
|
|
28
|
+
function invalidRequest(message = 'Invalid request') {
|
|
29
|
+
return new ApiKeyError(400, 'invalid_request', message);
|
|
30
|
+
}
|
|
31
|
+
function keyResolutionUnavailable(retryAfter, message = 'Key resolution unavailable') {
|
|
32
|
+
return new ApiKeyError(503, 'key_resolution_unavailable', message, retryAfter);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS1lcnJvci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hcGkta2V5L2FwaS1rZXktZXJyb3IudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7O0FBNEJBLGtDQUVDO0FBRUQsc0NBRUM7QUFFRCw0REFFQztBQUVELHdDQUVDO0FBRUQsNERBS0M7QUEzQ0QsTUFBYSxXQUFZLFNBQVEsS0FBSztJQUtwQyxZQUFZLE1BQWMsRUFBRSxJQUFxQixFQUFFLE9BQWUsRUFBRSxVQUFtQjtRQUNyRixLQUFLLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDZixJQUFJLENBQUMsSUFBSSxHQUFHLGFBQWEsQ0FBQztRQUMxQixJQUFJLENBQUMsTUFBTSxHQUFHLE1BQU0sQ0FBQztRQUNyQixJQUFJLENBQUMsSUFBSSxHQUFHLElBQUksQ0FBQztRQUNqQixJQUFJLENBQUMsVUFBVSxHQUFHLFVBQVUsQ0FBQztJQUMvQixDQUFDO0NBQ0Y7QUFaRCxrQ0FZQztBQVVELFNBQWdCLFdBQVcsQ0FBQyxLQUFrQjtJQUM1QyxPQUFPLEVBQUUsS0FBSyxFQUFFLEVBQUUsSUFBSSxFQUFFLEtBQUssQ0FBQyxJQUFJLEVBQUUsTUFBTSxFQUFFLEtBQUssQ0FBQyxNQUFNLEVBQUUsT0FBTyxFQUFFLEtBQUssQ0FBQyxPQUFPLEVBQUUsRUFBRSxDQUFDO0FBQ3ZGLENBQUM7QUFFRCxTQUFnQixhQUFhLENBQUMsT0FBTyxHQUFHLGlCQUFpQjtJQUN2RCxPQUFPLElBQUksV0FBVyxDQUFDLEdBQUcsRUFBRSxpQkFBaUIsRUFBRSxPQUFPLENBQUMsQ0FBQztBQUMxRCxDQUFDO0FBRUQsU0FBZ0Isd0JBQXdCLENBQUMsT0FBTyxHQUFHLDZCQUE2QjtJQUM5RSxPQUFPLElBQUksV0FBVyxDQUFDLEdBQUcsRUFBRSw2QkFBNkIsRUFBRSxPQUFPLENBQUMsQ0FBQztBQUN0RSxDQUFDO0FBRUQsU0FBZ0IsY0FBYyxDQUFDLE9BQU8sR0FBRyxpQkFBaUI7SUFDeEQsT0FBTyxJQUFJLFdBQVcsQ0FBQyxHQUFHLEVBQUUsaUJBQWlCLEVBQUUsT0FBTyxDQUFDLENBQUM7QUFDMUQsQ0FBQztBQUVELFNBQWdCLHdCQUF3QixDQUN0QyxVQUFrQixFQUNsQixPQUFPLEdBQUcsNEJBQTRCO0lBRXRDLE9BQU8sSUFBSSxXQUFXLENBQUMsR0FBRyxFQUFFLDRCQUE0QixFQUFFLE9BQU8sRUFBRSxVQUFVLENBQUMsQ0FBQztBQUNqRixDQUFDIn0=
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ApiKeyAuthenticator } from './api-key-authenticator';
|
|
2
|
+
import type { Logger } from '../ports/logger-port';
|
|
3
|
+
import type { Middleware } from 'koa';
|
|
4
|
+
export declare const BFF_KEY_HEADER = "X-Forest-Bff-Key";
|
|
5
|
+
export interface ApiKeyMiddlewareOptions {
|
|
6
|
+
authenticator: ApiKeyAuthenticator;
|
|
7
|
+
logger: Logger;
|
|
8
|
+
}
|
|
9
|
+
export default function createApiKeyMiddleware({ authenticator, logger, }: ApiKeyMiddlewareOptions): Middleware;
|
|
10
|
+
//# sourceMappingURL=api-key-middleware.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BFF_KEY_HEADER = void 0;
|
|
4
|
+
exports.default = createApiKeyMiddleware;
|
|
5
|
+
const api_key_1 = require("./api-key");
|
|
6
|
+
const api_key_error_1 = require("./api-key-error");
|
|
7
|
+
exports.BFF_KEY_HEADER = 'X-Forest-Bff-Key';
|
|
8
|
+
// On authentication failure this middleware rethrows `ApiKeyError` rather than
|
|
9
|
+
// writing the response itself; it must be mounted behind an error middleware
|
|
10
|
+
// that serializes the structured body (see `createErrorMiddleware`).
|
|
11
|
+
function createApiKeyMiddleware({ authenticator, logger, }) {
|
|
12
|
+
return async function apiKeyMiddleware(ctx, next) {
|
|
13
|
+
const rawKey = ctx.get(exports.BFF_KEY_HEADER);
|
|
14
|
+
if (!rawKey) {
|
|
15
|
+
await next();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let authenticated;
|
|
19
|
+
try {
|
|
20
|
+
authenticated = await authenticator.authenticate(rawKey);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof api_key_error_1.ApiKeyError) {
|
|
24
|
+
logger('Warn', 'BFF API key rejected', {
|
|
25
|
+
keyHash: (0, api_key_1.fingerprintApiKey)(rawKey),
|
|
26
|
+
type: error.type,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
logger('Error', 'BFF API key middleware failure', {
|
|
31
|
+
keyHash: (0, api_key_1.fingerprintApiKey)(rawKey),
|
|
32
|
+
cause: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
ctx.state.agentToken = authenticated.agentToken;
|
|
38
|
+
ctx.state.apiKeyIdentity = authenticated.identity;
|
|
39
|
+
ctx.set('Cache-Control', 'no-store');
|
|
40
|
+
logger('Info', 'Resolved BFF API key', {
|
|
41
|
+
keyHash: (0, api_key_1.fingerprintApiKey)(rawKey),
|
|
42
|
+
renderingId: authenticated.identity.renderingId,
|
|
43
|
+
});
|
|
44
|
+
await next();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS1taWRkbGV3YXJlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FwaS1rZXkvYXBpLWtleS1taWRkbGV3YXJlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQWlCQSx5Q0EyQ0M7QUF4REQsdUNBQThDO0FBQzlDLG1EQUE4QztBQUVqQyxRQUFBLGNBQWMsR0FBRyxrQkFBa0IsQ0FBQztBQU9qRCwrRUFBK0U7QUFDL0UsNkVBQTZFO0FBQzdFLHFFQUFxRTtBQUNyRSxTQUF3QixzQkFBc0IsQ0FBQyxFQUM3QyxhQUFhLEVBQ2IsTUFBTSxHQUNrQjtJQUN4QixPQUFPLEtBQUssVUFBVSxnQkFBZ0IsQ0FBQyxHQUFHLEVBQUUsSUFBSTtRQUM5QyxNQUFNLE1BQU0sR0FBRyxHQUFHLENBQUMsR0FBRyxDQUFDLHNCQUFjLENBQUMsQ0FBQztRQUV2QyxJQUFJLENBQUMsTUFBTSxFQUFFLENBQUM7WUFDWixNQUFNLElBQUksRUFBRSxDQUFDO1lBRWIsT0FBTztRQUNULENBQUM7UUFFRCxJQUFJLGFBQWtDLENBQUM7UUFFdkMsSUFBSSxDQUFDO1lBQ0gsYUFBYSxHQUFHLE1BQU0sYUFBYSxDQUFDLFlBQVksQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRCxDQUFDO1FBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztZQUNmLElBQUksS0FBSyxZQUFZLDJCQUFXLEVBQUUsQ0FBQztnQkFDakMsTUFBTSxDQUFDLE1BQU0sRUFBRSxzQkFBc0IsRUFBRTtvQkFDckMsT0FBTyxFQUFFLElBQUEsMkJBQWlCLEVBQUMsTUFBTSxDQUFDO29CQUNsQyxJQUFJLEVBQUUsS0FBSyxDQUFDLElBQUk7aUJBQ2pCLENBQUMsQ0FBQztZQUNMLENBQUM7aUJBQU0sQ0FBQztnQkFDTixNQUFNLENBQUMsT0FBTyxFQUFFLGdDQUFnQyxFQUFFO29CQUNoRCxPQUFPLEVBQUUsSUFBQSwyQkFBaUIsRUFBQyxNQUFNLENBQUM7b0JBQ2xDLEtBQUssRUFBRSxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxHQUFHLEtBQUssQ0FBQyxJQUFJLEtBQUssS0FBSyxDQUFDLE9BQU8sRUFBRSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDO2lCQUNsRixDQUFDLENBQUM7WUFDTCxDQUFDO1lBRUQsTUFBTSxLQUFLLENBQUM7UUFDZCxDQUFDO1FBRUQsR0FBRyxDQUFDLEtBQUssQ0FBQyxVQUFVLEdBQUcsYUFBYSxDQUFDLFVBQVUsQ0FBQztRQUNoRCxHQUFHLENBQUMsS0FBSyxDQUFDLGNBQWMsR0FBRyxhQUFhLENBQUMsUUFBUSxDQUFDO1FBQ2xELEdBQUcsQ0FBQyxHQUFHLENBQUMsZUFBZSxFQUFFLFVBQVUsQ0FBQyxDQUFDO1FBQ3JDLE1BQU0sQ0FBQyxNQUFNLEVBQUUsc0JBQXNCLEVBQUU7WUFDckMsT0FBTyxFQUFFLElBQUEsMkJBQWlCLEVBQUMsTUFBTSxDQUFDO1lBQ2xDLFdBQVcsRUFBRSxhQUFhLENBQUMsUUFBUSxDQUFDLFdBQVc7U0FDaEQsQ0FBQyxDQUFDO1FBRUgsTUFBTSxJQUFJLEVBQUUsQ0FBQztJQUNmLENBQUMsQ0FBQztBQUNKLENBQUMifQ==
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ApiKeyResolveErrorParams {
|
|
2
|
+
status?: number;
|
|
3
|
+
code?: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
retryAfter?: number;
|
|
6
|
+
unreachable?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare class ApiKeyResolveError extends Error {
|
|
9
|
+
readonly status?: number;
|
|
10
|
+
readonly code?: string;
|
|
11
|
+
readonly saasName?: string;
|
|
12
|
+
readonly retryAfter?: number;
|
|
13
|
+
readonly unreachable: boolean;
|
|
14
|
+
constructor(params: ApiKeyResolveErrorParams);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=api-key-resolve-error.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ApiKeyResolveError = void 0;
|
|
4
|
+
class ApiKeyResolveError extends Error {
|
|
5
|
+
constructor(params) {
|
|
6
|
+
super(`BFF API key resolve failed${params.status ? ` (${params.status})` : ''}`);
|
|
7
|
+
this.name = 'ApiKeyResolveError';
|
|
8
|
+
this.status = params.status;
|
|
9
|
+
this.code = params.code;
|
|
10
|
+
this.saasName = params.name;
|
|
11
|
+
this.retryAfter = params.retryAfter;
|
|
12
|
+
this.unreachable = params.unreachable ?? false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.ApiKeyResolveError = ApiKeyResolveError;
|
|
16
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS1yZXNvbHZlLWVycm9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL2FwaS1rZXkvYXBpLWtleS1yZXNvbHZlLWVycm9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiI7OztBQVFBLE1BQWEsa0JBQW1CLFNBQVEsS0FBSztJQU8zQyxZQUFZLE1BQWdDO1FBQzFDLEtBQUssQ0FBQyw2QkFBNkIsTUFBTSxDQUFDLE1BQU0sQ0FBQyxDQUFDLENBQUMsS0FBSyxNQUFNLENBQUMsTUFBTSxHQUFHLENBQUMsQ0FBQyxDQUFDLEVBQUUsRUFBRSxDQUFDLENBQUM7UUFDakYsSUFBSSxDQUFDLElBQUksR0FBRyxvQkFBb0IsQ0FBQztRQUNqQyxJQUFJLENBQUMsTUFBTSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7UUFDNUIsSUFBSSxDQUFDLElBQUksR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDO1FBQ3hCLElBQUksQ0FBQyxRQUFRLEdBQUcsTUFBTSxDQUFDLElBQUksQ0FBQztRQUM1QixJQUFJLENBQUMsVUFBVSxHQUFHLE1BQU0sQ0FBQyxVQUFVLENBQUM7UUFDcEMsSUFBSSxDQUFDLFdBQVcsR0FBRyxNQUFNLENBQUMsV0FBVyxJQUFJLEtBQUssQ0FBQztJQUNqRCxDQUFDO0NBQ0Y7QUFoQkQsZ0RBZ0JDIn0=
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ParsedApiKey {
|
|
2
|
+
keyId: string;
|
|
3
|
+
secret: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseApiKey(raw: string): ParsedApiKey | null;
|
|
6
|
+
export declare function hashApiKey(keyId: string, secret: string): string;
|
|
7
|
+
export declare function fingerprintApiKey(raw: string): string;
|
|
8
|
+
//# sourceMappingURL=api-key.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseApiKey = parseApiKey;
|
|
7
|
+
exports.hashApiKey = hashApiKey;
|
|
8
|
+
exports.fingerprintApiKey = fingerprintApiKey;
|
|
9
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
10
|
+
const API_KEY_PATTERN = /^fbff_([0-9a-f]{16})_([0-9a-f]{64})$/;
|
|
11
|
+
function parseApiKey(raw) {
|
|
12
|
+
const match = API_KEY_PATTERN.exec(raw);
|
|
13
|
+
if (!match)
|
|
14
|
+
return null;
|
|
15
|
+
return { keyId: match[1], secret: match[2] };
|
|
16
|
+
}
|
|
17
|
+
function hashApiKey(keyId, secret) {
|
|
18
|
+
return crypto_1.default.createHash('sha256').update(`${keyId}:${secret}`).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
function fingerprintApiKey(raw) {
|
|
21
|
+
return crypto_1.default.createHash('sha256').update(raw).digest('hex').slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLWtleS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy9hcGkta2V5L2FwaS1rZXkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6Ijs7Ozs7QUFTQSxrQ0FNQztBQUVELGdDQUVDO0FBRUQsOENBRUM7QUF2QkQsb0RBQTRCO0FBRTVCLE1BQU0sZUFBZSxHQUFHLHNDQUFzQyxDQUFDO0FBTy9ELFNBQWdCLFdBQVcsQ0FBQyxHQUFXO0lBQ3JDLE1BQU0sS0FBSyxHQUFHLGVBQWUsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7SUFFeEMsSUFBSSxDQUFDLEtBQUs7UUFBRSxPQUFPLElBQUksQ0FBQztJQUV4QixPQUFPLEVBQUUsS0FBSyxFQUFFLEtBQUssQ0FBQyxDQUFDLENBQUMsRUFBRSxNQUFNLEVBQUUsS0FBSyxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7QUFDL0MsQ0FBQztBQUVELFNBQWdCLFVBQVUsQ0FBQyxLQUFhLEVBQUUsTUFBYztJQUN0RCxPQUFPLGdCQUFNLENBQUMsVUFBVSxDQUFDLFFBQVEsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLEtBQUssSUFBSSxNQUFNLEVBQUUsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztBQUNoRixDQUFDO0FBRUQsU0FBZ0IsaUJBQWlCLENBQUMsR0FBVztJQUMzQyxPQUFPLGdCQUFNLENBQUMsVUFBVSxDQUFDLFFBQVEsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUMsRUFBRSxFQUFFLENBQUMsQ0FBQztBQUM1RSxDQUFDIn0=
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedApiKeyIdentity } from './api-key-client';
|
|
2
|
+
import type { ApiKeyError } from './api-key-error';
|
|
3
|
+
export interface ResolveCache {
|
|
4
|
+
getPositive(hash: string): ResolvedApiKeyIdentity | undefined;
|
|
5
|
+
getNegative(hash: string): ApiKeyError | undefined;
|
|
6
|
+
setPositive(hash: string, identity: ResolvedApiKeyIdentity): void;
|
|
7
|
+
setNegative(hash: string, error: ApiKeyError): void;
|
|
8
|
+
size(): number;
|
|
9
|
+
}
|
|
10
|
+
export interface ResolveCacheOptions {
|
|
11
|
+
now: () => number;
|
|
12
|
+
positiveTtlSeconds?: number;
|
|
13
|
+
negativeTtlSeconds?: number;
|
|
14
|
+
maxEntries?: number;
|
|
15
|
+
}
|
|
16
|
+
export default function createResolveCache({ now, positiveTtlSeconds, negativeTtlSeconds, maxEntries, }: ResolveCacheOptions): ResolveCache;
|
|
17
|
+
//# sourceMappingURL=resolve-cache.d.ts.map
|