@finctl/mcp 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/README.md +224 -0
- package/dist/auth/authenticator.d.ts +37 -0
- package/dist/auth/authenticator.js +66 -0
- package/dist/auth/authenticator.js.map +1 -0
- package/dist/auth/backend-store.d.ts +19 -0
- package/dist/auth/backend-store.js +57 -0
- package/dist/auth/backend-store.js.map +1 -0
- package/dist/auth/context.d.ts +23 -0
- package/dist/auth/context.js +30 -0
- package/dist/auth/context.js.map +1 -0
- package/dist/auth/rate-limit.d.ts +17 -0
- package/dist/auth/rate-limit.js +22 -0
- package/dist/auth/rate-limit.js.map +1 -0
- package/dist/auth/store.d.ts +47 -0
- package/dist/auth/store.js +62 -0
- package/dist/auth/store.js.map +1 -0
- package/dist/client/finctl-client.d.ts +212 -0
- package/dist/client/finctl-client.js +131 -0
- package/dist/client/finctl-client.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +35 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/catalog.d.ts +36 -0
- package/dist/tools/catalog.js +417 -0
- package/dist/tools/catalog.js.map +1 -0
- package/dist/transports/http.d.ts +17 -0
- package/dist/transports/http.js +99 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/stdio.d.ts +14 -0
- package/dist/transports/stdio.js +37 -0
- package/dist/transports/stdio.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +22 -0
- package/dist/version.js.map +1 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# @finctl/mcp
|
|
2
|
+
|
|
3
|
+
FinCtl's [Model Context Protocol](https://modelcontextprotocol.io) server. Exposes AWS
|
|
4
|
+
cost intelligence — spend summaries, top services, savings recommendations, rightsizing,
|
|
5
|
+
forecasts, anomalies, and account info — directly inside AI-assisted dev tools (Cursor,
|
|
6
|
+
VS Code Copilot, Kiro, Claude Code, and any MCP-compatible client).
|
|
7
|
+
|
|
8
|
+
> **Status:** all 10 tools return live data from the FinCtl backend when an endpoint is
|
|
9
|
+
> configured (FIN-1467/1468/1469). Without an endpoint, each cost tool returns a clear
|
|
10
|
+
> "not configured" message.
|
|
11
|
+
|
|
12
|
+
## Install & run
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
export FINCTL_API_KEY=fct_live_xxxx # required
|
|
16
|
+
npx @finctl/mcp # stdio transport, no install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install globally:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @finctl/mcp
|
|
23
|
+
finctl-mcp --version
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### CLI options
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
finctl-mcp [options]
|
|
30
|
+
|
|
31
|
+
--http Run the HTTP/SSE transport instead of stdio
|
|
32
|
+
--port <n> HTTP port (default 3000; or PORT env)
|
|
33
|
+
--endpoint <url> FinCtl backend base URL (or FINCTL_ENDPOINT env)
|
|
34
|
+
-v, --version Print version and exit
|
|
35
|
+
-h, --help Print usage and exit
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`--version` and `--help` work without an API key; starting the server requires
|
|
39
|
+
`FINCTL_API_KEY`.
|
|
40
|
+
|
|
41
|
+
### From source
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install
|
|
45
|
+
npm run build && node dist/index.js # stdio
|
|
46
|
+
npm run dev # stdio, no build (tsx)
|
|
47
|
+
npm run dev:http # HTTP/SSE on :3000
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## IDE integrations
|
|
51
|
+
|
|
52
|
+
| Tool | Guide |
|
|
53
|
+
| ---- | ----- |
|
|
54
|
+
| Cursor | [docs/integrations/cursor.md](docs/integrations/cursor.md) |
|
|
55
|
+
| Claude Code | [docs/integrations/claude-code.md](docs/integrations/claude-code.md) |
|
|
56
|
+
| Cline / Roo Code | [docs/integrations/cline.md](docs/integrations/cline.md) |
|
|
57
|
+
| Amazon Q Developer | [docs/integrations/amazon-q.md](docs/integrations/amazon-q.md) |
|
|
58
|
+
| VS Code (GitHub Copilot) | [docs/integrations/vscode.md](docs/integrations/vscode.md) |
|
|
59
|
+
| Kiro | [docs/integrations/kiro.md](docs/integrations/kiro.md) |
|
|
60
|
+
| Continue.dev | [docs/integrations/continue.md](docs/integrations/continue.md) |
|
|
61
|
+
| Windsurf | [docs/integrations/windsurf.md](docs/integrations/windsurf.md) |
|
|
62
|
+
| JetBrains AI Assistant | [docs/integrations/jetbrains.md](docs/integrations/jetbrains.md) |
|
|
63
|
+
| OpenAI Codex CLI | [docs/integrations/codex.md](docs/integrations/codex.md) |
|
|
64
|
+
| Raycast | [docs/integrations/raycast.md](docs/integrations/raycast.md) |
|
|
65
|
+
|
|
66
|
+
Verify every tool is callable end-to-end (the same round-trip an IDE performs):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
FINCTL_API_KEY=your-key npm run validate
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Transports
|
|
73
|
+
|
|
74
|
+
| Transport | Command | Used by |
|
|
75
|
+
| -- | -- | -- |
|
|
76
|
+
| **stdio** | `finctl-mcp` (default) | Local IDE integrations — the client spawns the process and speaks JSON-RPC over stdin/stdout. |
|
|
77
|
+
| **HTTP/SSE** | `finctl-mcp --http [--port N]` | Hosted/remote connections and web MCP clients. Uses the Streamable HTTP transport (carries server→client messages as Server-Sent Events). |
|
|
78
|
+
|
|
79
|
+
The HTTP server exposes:
|
|
80
|
+
|
|
81
|
+
- `POST /mcp` — MCP endpoint (stateless)
|
|
82
|
+
- `GET /health` — liveness check
|
|
83
|
+
|
|
84
|
+
For a hosted endpoint on AWS (Docker + ECS Fargate behind an ALB, SSE-tuned), see
|
|
85
|
+
[docs/deployment/aws.md](docs/deployment/aws.md). For the per-customer (one
|
|
86
|
+
isolated AWS account each) deploy runbook, see
|
|
87
|
+
[docs/deployment/per-customer-deployment.md](docs/deployment/per-customer-deployment.md).
|
|
88
|
+
Quick local container run:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
docker build -t finctl-mcp .
|
|
92
|
+
docker run --rm -p 3000:3000 -e FINCTL_API_KEY=fct_live_xxxx finctl-mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Tool catalog
|
|
96
|
+
|
|
97
|
+
| Tool | Description |
|
|
98
|
+
| -- | -- |
|
|
99
|
+
| `get_cost_summary` | Total spend, top accounts, top services |
|
|
100
|
+
| `list_top_services` | Top services by cost |
|
|
101
|
+
| `get_recommendations` | Active savings recommendations |
|
|
102
|
+
| `get_rightsizing` | EC2/RDS rightsizing opportunities |
|
|
103
|
+
| `get_resource_details` | One resource's type, account, active recommendations |
|
|
104
|
+
| `get_forecast` | Projected spend, growth, budget variance |
|
|
105
|
+
| `get_anomalies` | Recent cost anomalies, sorted by severity |
|
|
106
|
+
| `get_budget_status` | Budgets: spend, % consumed, projected, on-track |
|
|
107
|
+
| `list_accounts` | Linked AWS accounts |
|
|
108
|
+
| `get_savings_plans_coverage` | RI/SP coverage and waste |
|
|
109
|
+
|
|
110
|
+
Tool definitions (names, descriptions, input schemas) live in
|
|
111
|
+
[`src/tools/catalog.ts`](src/tools/catalog.ts). Adding a tool = adding one entry there.
|
|
112
|
+
|
|
113
|
+
## Project structure
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
finctl-mcp/
|
|
117
|
+
├── package.json
|
|
118
|
+
├── tsconfig.json
|
|
119
|
+
├── README.md
|
|
120
|
+
├── scripts/
|
|
121
|
+
│ └── smoke.mjs # build-time smoke test (version, handshake, tool list)
|
|
122
|
+
├── .github/workflows/ # CI (build + test) and Publish (on version tag)
|
|
123
|
+
└── src/
|
|
124
|
+
├── index.ts # entrypoint — flags + transport selection
|
|
125
|
+
├── version.ts # runtime version (reads package.json)
|
|
126
|
+
├── server.ts # builds the McpServer, registers the catalog
|
|
127
|
+
├── auth/ # API key validation, scoping, rate limiting
|
|
128
|
+
├── client/
|
|
129
|
+
│ └── finctl-client.ts # HTTP client for the FinCtl backend
|
|
130
|
+
├── tools/
|
|
131
|
+
│ └── catalog.ts # tool definitions + handlers
|
|
132
|
+
└── transports/
|
|
133
|
+
├── stdio.ts # local IDE transport
|
|
134
|
+
└── http.ts # hosted Streamable HTTP/SSE transport
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Publishing
|
|
138
|
+
|
|
139
|
+
CI (`.github/workflows`) builds and runs the smoke test on every push/PR. Pushing a
|
|
140
|
+
version tag publishes to npm:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm version patch # bump package.json + create vX.Y.Z tag
|
|
144
|
+
git push --follow-tags # CI publishes @finctl/mcp on the tag
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The publish workflow verifies the tag matches `package.json`, runs build + test, then
|
|
148
|
+
`npm publish --provenance --access public`. Requires an `NPM_TOKEN` repo secret with
|
|
149
|
+
publish rights to the `@finctl` scope — see
|
|
150
|
+
[docs/deployment/npm-publishing.md](docs/deployment/npm-publishing.md) for one-time token
|
|
151
|
+
setup and the release steps.
|
|
152
|
+
|
|
153
|
+
> Stretch (not yet done): Homebrew tap (`brew install finctl/tap/finctl-mcp`) and
|
|
154
|
+
> automated changelog generation.
|
|
155
|
+
|
|
156
|
+
## Verifying the handshake
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
# stdio: send an initialize request and read the response (key required)
|
|
160
|
+
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' \
|
|
161
|
+
| FINCTL_API_KEY=fct_test_local node dist/index.js
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Authentication
|
|
165
|
+
|
|
166
|
+
Every request authenticates with a per-customer API key. The key resolves to a customer
|
|
167
|
+
ID and account scope; all tool responses carry that scope, so a customer only ever sees
|
|
168
|
+
their own data.
|
|
169
|
+
|
|
170
|
+
| Transport | How the key is passed |
|
|
171
|
+
| -- | -- |
|
|
172
|
+
| stdio | `FINCTL_API_KEY` environment variable (one customer per process; missing/invalid key fails fast at startup) |
|
|
173
|
+
| HTTP | `Authorization: Bearer <key>` header (validated per request) |
|
|
174
|
+
|
|
175
|
+
Invalid or missing keys are rejected before reaching the MCP layer (JSON-RPC error
|
|
176
|
+
`-32001`, HTTP 401). Each key is rate-limited (default 60 req/min, JSON-RPC `-32002`,
|
|
177
|
+
HTTP 429).
|
|
178
|
+
|
|
179
|
+
### Configuration (env)
|
|
180
|
+
|
|
181
|
+
| Var | Purpose | Default |
|
|
182
|
+
| -- | -- | -- |
|
|
183
|
+
| `FINCTL_API_KEY` | Active API key | — (required) |
|
|
184
|
+
| `FINCTL_CUSTOMER_ID` | Customer the key maps to | `local` |
|
|
185
|
+
| `FINCTL_ACCOUNT_IDS` | Comma-separated AWS account scope (empty = all customer accounts) | — |
|
|
186
|
+
| `FINCTL_RATE_LIMIT` | Requests per minute per key | `60` |
|
|
187
|
+
| `FINCTL_API_KEY_PREVIOUS` | Prior key, accepted during rotation grace | — |
|
|
188
|
+
| `FINCTL_API_KEY_PREVIOUS_EXPIRES_AT` | Epoch ms after which the prior key is rejected | — |
|
|
189
|
+
|
|
190
|
+
**Zero-downtime rotation:** set the new key as `FINCTL_API_KEY`, the old one as
|
|
191
|
+
`FINCTL_API_KEY_PREVIOUS`, and `FINCTL_API_KEY_PREVIOUS_EXPIRES_AT` to ~5 min out. Both
|
|
192
|
+
keys work until the old one expires.
|
|
193
|
+
|
|
194
|
+
## Backend connection
|
|
195
|
+
|
|
196
|
+
The cost tools proxy to the FinCtl backend (the `dashboard-api` Lambda in `finctl-core`)
|
|
197
|
+
rather than querying AWS directly. Point the server at a backend:
|
|
198
|
+
|
|
199
|
+
| Var | Purpose |
|
|
200
|
+
| -- | -- |
|
|
201
|
+
| `FINCTL_ENDPOINT` (or `FINCTL_DASHBOARD_API_URL`) | Backend base URL. Without it, cost tools return a clear "not configured" error. |
|
|
202
|
+
| `FINCTL_MCP_SERVICE_SECRET` | Shared service secret (= backend `MCP_VALIDATE_SECRET`). Authenticates the MCP server to the backend. |
|
|
203
|
+
| `FINCTL_BACKEND_TOKEN` | Optional Cognito Bearer token, if a deployment uses one. |
|
|
204
|
+
|
|
205
|
+
Each request forwards `X-Finctl-Mcp-Secret: <FINCTL_MCP_SERVICE_SECRET>` and
|
|
206
|
+
`X-Customer-Id: <customerId>` (resolved from the caller's API key) so the backend
|
|
207
|
+
authenticates and scopes the data (FIN-2648). Endpoints used:
|
|
208
|
+
`/api/accounts`, `/api/accounts/spend-summary`, `/api/resources/top-cost`,
|
|
209
|
+
`/api/recommendations`, `/api/org/sp-utilization`, `/api/org/ri-utilization`,
|
|
210
|
+
`/api/forecast`, `/api/anomalies`, `/api/budgets`.
|
|
211
|
+
|
|
212
|
+
### Key store
|
|
213
|
+
|
|
214
|
+
Key validation goes through a pluggable [`KeyStore`](src/auth/store.ts) interface:
|
|
215
|
+
|
|
216
|
+
- **`BackendKeyStore`** (production) — when `FINCTL_ENDPOINT` + `FINCTL_MCP_SERVICE_SECRET`
|
|
217
|
+
are set, presented keys are validated against the backend's
|
|
218
|
+
`POST /api/mcp-keys/validate` (FIN-2646), which resolves the customer scope from the
|
|
219
|
+
portal-generated key records. Positive results are cached ~60s.
|
|
220
|
+
- **`EnvKeyStore`** (local/dev) — used when no backend is configured; validates a single
|
|
221
|
+
`FINCTL_API_KEY` from the env (see the table above), with rotation grace.
|
|
222
|
+
|
|
223
|
+
Customer-facing key generation/revocation lives in the FinCtl backend + portal UI
|
|
224
|
+
(FIN-1475); this repo consumes those keys.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AuthContext } from "./context.js";
|
|
2
|
+
import { type KeyStore } from "./store.js";
|
|
3
|
+
import { type RateLimiter } from "./rate-limit.js";
|
|
4
|
+
export type AuthErrorCode = "unauthorized" | "rate_limited";
|
|
5
|
+
/** Authentication/authorization failure, mapped to a JSON-RPC error by callers. */
|
|
6
|
+
export declare class AuthError extends Error {
|
|
7
|
+
readonly code: AuthErrorCode;
|
|
8
|
+
constructor(code: AuthErrorCode, message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validates a presented API key and enforces per-key rate limiting, producing
|
|
12
|
+
* the {@link AuthContext} that scopes a request to one customer.
|
|
13
|
+
*/
|
|
14
|
+
export declare class Authenticator {
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly limiter;
|
|
17
|
+
constructor(store: KeyStore, limiter: RateLimiter);
|
|
18
|
+
authenticate(presentedKey: string | undefined): Promise<AuthContext>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the default authenticator from environment configuration:
|
|
22
|
+
* an {@link EnvKeyStore} plus a 60 req/min per-key limiter.
|
|
23
|
+
*
|
|
24
|
+
* FINCTL_RATE_LIMIT requests per minute per key (default 60)
|
|
25
|
+
*
|
|
26
|
+
* Store selection: when a backend endpoint and the shared MCP service secret are
|
|
27
|
+
* configured, keys are validated against the backend (production — customers'
|
|
28
|
+
* portal-generated keys live there). Otherwise an env-backed key is used for
|
|
29
|
+
* local dev.
|
|
30
|
+
*
|
|
31
|
+
* FINCTL_ENDPOINT | FINCTL_DASHBOARD_API_URL backend base URL
|
|
32
|
+
* FINCTL_MCP_SERVICE_SECRET = backend MCP_VALIDATE_SECRET
|
|
33
|
+
*/
|
|
34
|
+
export declare function createAuthenticator(env?: NodeJS.ProcessEnv): {
|
|
35
|
+
authenticator: Authenticator;
|
|
36
|
+
store: KeyStore;
|
|
37
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { EnvKeyStore } from "./store.js";
|
|
2
|
+
import { BackendKeyStore } from "./backend-store.js";
|
|
3
|
+
import { FixedWindowRateLimiter } from "./rate-limit.js";
|
|
4
|
+
/** Authentication/authorization failure, mapped to a JSON-RPC error by callers. */
|
|
5
|
+
export class AuthError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "AuthError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Validates a presented API key and enforces per-key rate limiting, producing
|
|
15
|
+
* the {@link AuthContext} that scopes a request to one customer.
|
|
16
|
+
*/
|
|
17
|
+
export class Authenticator {
|
|
18
|
+
store;
|
|
19
|
+
limiter;
|
|
20
|
+
constructor(store, limiter) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
this.limiter = limiter;
|
|
23
|
+
}
|
|
24
|
+
async authenticate(presentedKey) {
|
|
25
|
+
if (!presentedKey) {
|
|
26
|
+
throw new AuthError("unauthorized", "Missing API key");
|
|
27
|
+
}
|
|
28
|
+
const validation = await this.store.validate(presentedKey);
|
|
29
|
+
if (!validation) {
|
|
30
|
+
throw new AuthError("unauthorized", "Invalid API key");
|
|
31
|
+
}
|
|
32
|
+
if (!this.limiter.allow(validation.keyId)) {
|
|
33
|
+
throw new AuthError("rate_limited", "Rate limit exceeded");
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
keyId: validation.keyId,
|
|
37
|
+
customerId: validation.customerId,
|
|
38
|
+
accountIds: validation.accountIds,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build the default authenticator from environment configuration:
|
|
44
|
+
* an {@link EnvKeyStore} plus a 60 req/min per-key limiter.
|
|
45
|
+
*
|
|
46
|
+
* FINCTL_RATE_LIMIT requests per minute per key (default 60)
|
|
47
|
+
*
|
|
48
|
+
* Store selection: when a backend endpoint and the shared MCP service secret are
|
|
49
|
+
* configured, keys are validated against the backend (production — customers'
|
|
50
|
+
* portal-generated keys live there). Otherwise an env-backed key is used for
|
|
51
|
+
* local dev.
|
|
52
|
+
*
|
|
53
|
+
* FINCTL_ENDPOINT | FINCTL_DASHBOARD_API_URL backend base URL
|
|
54
|
+
* FINCTL_MCP_SERVICE_SECRET = backend MCP_VALIDATE_SECRET
|
|
55
|
+
*/
|
|
56
|
+
export function createAuthenticator(env = process.env) {
|
|
57
|
+
const baseUrl = (env.FINCTL_ENDPOINT || env.FINCTL_DASHBOARD_API_URL)?.trim();
|
|
58
|
+
const serviceSecret = env.FINCTL_MCP_SERVICE_SECRET?.trim();
|
|
59
|
+
const store = baseUrl && serviceSecret
|
|
60
|
+
? new BackendKeyStore({ baseUrl, serviceSecret })
|
|
61
|
+
: new EnvKeyStore(env);
|
|
62
|
+
const limit = Number(env.FINCTL_RATE_LIMIT) || 60;
|
|
63
|
+
const authenticator = new Authenticator(store, new FixedWindowRateLimiter(limit));
|
|
64
|
+
return { authenticator, store };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=authenticator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authenticator.js","sourceRoot":"","sources":["../../src/auth/authenticator.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAiB,MAAM,YAAY,CAAC;AACxD,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,sBAAsB,EAAoB,MAAM,iBAAiB,CAAC;AAI3E,mFAAmF;AACnF,MAAM,OAAO,SAAU,SAAQ,KAAK;IAEvB;IADX,YACW,IAAmB,EAC5B,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,SAAI,GAAJ,IAAI,CAAe;QAI5B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,aAAa;IAEL;IACA;IAFnB,YACmB,KAAe,EACf,OAAoB;QADpB,UAAK,GAAL,KAAK,CAAU;QACf,YAAO,GAAP,OAAO,CAAa;IACpC,CAAC;IAEJ,KAAK,CAAC,YAAY,CAAC,YAAgC;QACjD,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC;QACzD,CAAC;QACD,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;QAC3D,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC;QACzD,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,cAAc,EAAE,qBAAqB,CAAC,CAAC;QAC7D,CAAC;QACD,OAAO;YACL,KAAK,EAAE,UAAU,CAAC,KAAK;YACvB,UAAU,EAAE,UAAU,CAAC,UAAU;YACjC,UAAU,EAAE,UAAU,CAAC,UAAU;SAClC,CAAC;IACJ,CAAC;CACF;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAyB,OAAO,CAAC,GAAG;IAItE,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,wBAAwB,CAAC,EAAE,IAAI,EAAE,CAAC;IAC9E,MAAM,aAAa,GAAG,GAAG,CAAC,yBAAyB,EAAE,IAAI,EAAE,CAAC;IAC5D,MAAM,KAAK,GACT,OAAO,IAAI,aAAa;QACtB,CAAC,CAAC,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC;QACjD,CAAC,CAAC,IAAI,WAAW,CAAC,GAAG,CAAC,CAAC;IAE3B,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAC;IAClD,MAAM,aAAa,GAAG,IAAI,aAAa,CAAC,KAAK,EAAE,IAAI,sBAAsB,CAAC,KAAK,CAAC,CAAC,CAAC;IAClF,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC;AAClC,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type KeyStore, type KeyValidation } from "./store.js";
|
|
2
|
+
export interface BackendKeyStoreOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Shared service secret (sent as X-Finctl-Mcp-Secret; = backend MCP_VALIDATE_SECRET). */
|
|
5
|
+
serviceSecret: string;
|
|
6
|
+
cacheTtlMs?: number;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare class BackendKeyStore implements KeyStore {
|
|
11
|
+
private readonly baseUrl;
|
|
12
|
+
private readonly serviceSecret;
|
|
13
|
+
private readonly cacheTtlMs;
|
|
14
|
+
private readonly timeoutMs;
|
|
15
|
+
private readonly fetchImpl;
|
|
16
|
+
private readonly cache;
|
|
17
|
+
constructor(opts: BackendKeyStoreOptions);
|
|
18
|
+
validate(presentedKey: string): Promise<KeyValidation | null>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { keyId } from "./store.js";
|
|
2
|
+
export class BackendKeyStore {
|
|
3
|
+
baseUrl;
|
|
4
|
+
serviceSecret;
|
|
5
|
+
cacheTtlMs;
|
|
6
|
+
timeoutMs;
|
|
7
|
+
fetchImpl;
|
|
8
|
+
cache = new Map();
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
11
|
+
this.serviceSecret = opts.serviceSecret;
|
|
12
|
+
this.cacheTtlMs = opts.cacheTtlMs ?? 60_000;
|
|
13
|
+
this.timeoutMs = opts.timeoutMs ?? 10_000;
|
|
14
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
15
|
+
}
|
|
16
|
+
async validate(presentedKey) {
|
|
17
|
+
const id = keyId(presentedKey);
|
|
18
|
+
const cached = this.cache.get(id);
|
|
19
|
+
if (cached && cached.expiresAt > Date.now())
|
|
20
|
+
return cached.validation;
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await this.fetchImpl(`${this.baseUrl}/api/mcp-keys/validate`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: {
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
Accept: "application/json",
|
|
30
|
+
"X-Finctl-Mcp-Secret": this.serviceSecret,
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({ key: presentedKey }),
|
|
33
|
+
signal: controller.signal,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Network/timeout — treat as not-valid (caller surfaces an auth error).
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
return null; // 401 = invalid/revoked key
|
|
45
|
+
const data = (await res.json());
|
|
46
|
+
if (!data.customerId)
|
|
47
|
+
return null;
|
|
48
|
+
const validation = {
|
|
49
|
+
keyId: id,
|
|
50
|
+
customerId: data.customerId,
|
|
51
|
+
accountIds: data.accountIds ?? [],
|
|
52
|
+
};
|
|
53
|
+
this.cache.set(id, { validation, expiresAt: Date.now() + this.cacheTtlMs });
|
|
54
|
+
return validation;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=backend-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backend-store.js","sourceRoot":"","sources":["../../src/auth/backend-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAqC,MAAM,YAAY,CAAC;AAyBtE,MAAM,OAAO,eAAe;IACT,OAAO,CAAS;IAChB,aAAa,CAAS;IACtB,UAAU,CAAS;IACnB,SAAS,CAAS;IAClB,SAAS,CAAe;IACxB,KAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;IAEvD,YAAY,IAA4B;QACtC,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAChD,IAAI,CAAC,aAAa,GAAG,IAAI,CAAC,aAAa,CAAC;QACxC,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,MAAM,CAAC;QAC5C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,MAAM,CAAC;QAC1C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC;IAC3C,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,YAAoB;QACjC,MAAM,EAAE,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC;QAE/B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAClC,IAAI,MAAM,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE;YAAE,OAAO,MAAM,CAAC,UAAU,CAAC;QAEtE,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACnE,IAAI,GAAa,CAAC;QAClB,IAAI,CAAC;YACH,GAAG,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO,wBAAwB,EAAE;gBAClE,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE;oBACP,cAAc,EAAE,kBAAkB;oBAClC,MAAM,EAAE,kBAAkB;oBAC1B,qBAAqB,EAAE,IAAI,CAAC,aAAa;iBAC1C;gBACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,YAAY,EAAE,CAAC;gBAC3C,MAAM,EAAE,UAAU,CAAC,MAAM;aAC1B,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,wEAAwE;YACxE,OAAO,IAAI,CAAC;QACd,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC,CAAC,4BAA4B;QACtD,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAmD,CAAC;QAClF,IAAI,CAAC,IAAI,CAAC,UAAU;YAAE,OAAO,IAAI,CAAC;QAElC,MAAM,UAAU,GAAkB;YAChC,KAAK,EAAE,EAAE;YACT,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,UAAU,EAAE,IAAI,CAAC,UAAU,IAAI,EAAE;SAClC,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC;QAC5E,OAAO,UAAU,CAAC;IACpB,CAAC;CACF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authenticated caller's scope. Resolved from an API key and made available
|
|
3
|
+
* to every tool handler so responses can be filtered to this customer's data —
|
|
4
|
+
* no cross-customer leakage.
|
|
5
|
+
*/
|
|
6
|
+
export interface AuthContext {
|
|
7
|
+
/** Stable, non-secret identifier for the key (safe to log). */
|
|
8
|
+
keyId: string;
|
|
9
|
+
/** Customer the key belongs to. All data queries filter by this. */
|
|
10
|
+
customerId: string;
|
|
11
|
+
/** AWS accounts this key may read. Empty = all of the customer's accounts. */
|
|
12
|
+
accountIds: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare function setProcessAuth(ctx: AuthContext | null): void;
|
|
15
|
+
/** Run `fn` with `ctx` as the active auth context (used per-request by HTTP). */
|
|
16
|
+
export declare function runWithAuth<T>(ctx: AuthContext, fn: () => T): T;
|
|
17
|
+
/** Current auth context, or null if none is active. */
|
|
18
|
+
export declare function currentAuthOrNull(): AuthContext | null;
|
|
19
|
+
/**
|
|
20
|
+
* Current auth context. Throws if none is active — tool handlers must never run
|
|
21
|
+
* unauthenticated, so this throwing is a safety net, not normal control flow.
|
|
22
|
+
*/
|
|
23
|
+
export declare function currentAuth(): AuthContext;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const als = new AsyncLocalStorage();
|
|
3
|
+
/**
|
|
4
|
+
* Process-wide fallback context. The stdio transport serves a single customer
|
|
5
|
+
* per process, so it sets this once at startup rather than wrapping every
|
|
6
|
+
* incoming message. HTTP uses {@link runWithAuth} per request instead.
|
|
7
|
+
*/
|
|
8
|
+
let processDefault = null;
|
|
9
|
+
export function setProcessAuth(ctx) {
|
|
10
|
+
processDefault = ctx;
|
|
11
|
+
}
|
|
12
|
+
/** Run `fn` with `ctx` as the active auth context (used per-request by HTTP). */
|
|
13
|
+
export function runWithAuth(ctx, fn) {
|
|
14
|
+
return als.run(ctx, fn);
|
|
15
|
+
}
|
|
16
|
+
/** Current auth context, or null if none is active. */
|
|
17
|
+
export function currentAuthOrNull() {
|
|
18
|
+
return als.getStore() ?? processDefault;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Current auth context. Throws if none is active — tool handlers must never run
|
|
22
|
+
* unauthenticated, so this throwing is a safety net, not normal control flow.
|
|
23
|
+
*/
|
|
24
|
+
export function currentAuth() {
|
|
25
|
+
const ctx = currentAuthOrNull();
|
|
26
|
+
if (!ctx)
|
|
27
|
+
throw new Error("No authentication context — request reached a tool handler unauthenticated");
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.js","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAgBrD,MAAM,GAAG,GAAG,IAAI,iBAAiB,EAAe,CAAC;AAEjD;;;;GAIG;AACH,IAAI,cAAc,GAAuB,IAAI,CAAC;AAE9C,MAAM,UAAU,cAAc,CAAC,GAAuB;IACpD,cAAc,GAAG,GAAG,CAAC;AACvB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,WAAW,CAAI,GAAgB,EAAE,EAAW;IAC1D,OAAO,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AAC1B,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,CAAC,QAAQ,EAAE,IAAI,cAAc,CAAC;AAC1C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW;IACzB,MAAM,GAAG,GAAG,iBAAiB,EAAE,CAAC;IAChC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAC;IACxG,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-key fixed-window rate limiter. In-memory — fine for a single stdio process
|
|
3
|
+
* and per-instance HTTP throttling. A hosted multi-instance deployment (FIN-1474)
|
|
4
|
+
* should swap this for a shared store (e.g. Redis/DynamoDB) behind the same
|
|
5
|
+
* {@link RateLimiter} interface.
|
|
6
|
+
*/
|
|
7
|
+
export interface RateLimiter {
|
|
8
|
+
/** Record one request for `key`; return false if it exceeds the limit. */
|
|
9
|
+
allow(key: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class FixedWindowRateLimiter implements RateLimiter {
|
|
12
|
+
private readonly limit;
|
|
13
|
+
private readonly windowMs;
|
|
14
|
+
private readonly windows;
|
|
15
|
+
constructor(limit?: number, windowMs?: number);
|
|
16
|
+
allow(key: string): boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class FixedWindowRateLimiter {
|
|
2
|
+
limit;
|
|
3
|
+
windowMs;
|
|
4
|
+
windows = new Map();
|
|
5
|
+
constructor(limit = 60, windowMs = 60_000) {
|
|
6
|
+
this.limit = limit;
|
|
7
|
+
this.windowMs = windowMs;
|
|
8
|
+
}
|
|
9
|
+
allow(key) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const win = this.windows.get(key);
|
|
12
|
+
if (!win || now >= win.resetAt) {
|
|
13
|
+
this.windows.set(key, { count: 1, resetAt: now + this.windowMs });
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (win.count >= this.limit)
|
|
17
|
+
return false;
|
|
18
|
+
win.count++;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/auth/rate-limit.ts"],"names":[],"mappings":"AAgBA,MAAM,OAAO,sBAAsB;IAId;IACA;IAJF,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAErD,YACmB,QAAQ,EAAE,EACV,WAAW,MAAM;QADjB,UAAK,GAAL,KAAK,CAAK;QACV,aAAQ,GAAR,QAAQ,CAAS;IACjC,CAAC;IAEJ,KAAK,CAAC,GAAW;QACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAElC,IAAI,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,GAAG,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;YAClE,OAAO,IAAI,CAAC;QACd,CAAC;QACD,IAAI,GAAG,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC;QAC1C,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved scope for a valid API key. Backends (env, DynamoDB, SSM) all produce
|
|
3
|
+
* this shape so the rest of the server is storage-agnostic.
|
|
4
|
+
*/
|
|
5
|
+
export interface KeyValidation {
|
|
6
|
+
/** Stable, non-secret key identifier — safe to log and rate-limit on. */
|
|
7
|
+
keyId: string;
|
|
8
|
+
customerId: string;
|
|
9
|
+
/** AWS accounts the key may read. Empty = all of the customer's accounts. */
|
|
10
|
+
accountIds: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Pluggable API key store. Production will back this with DynamoDB or SSM (key
|
|
14
|
+
* lookup → customer scope); see {@link EnvKeyStore} for the local/dev impl.
|
|
15
|
+
*/
|
|
16
|
+
export interface KeyStore {
|
|
17
|
+
/** Return the key's scope if valid, else null. Constant-time on the secret. */
|
|
18
|
+
validate(presentedKey: string): Promise<KeyValidation | null>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Derive a stable, non-secret identifier from a key. Logging the raw key would
|
|
22
|
+
* leak a credential; this hash prefix is safe to log and rate-limit on.
|
|
23
|
+
*/
|
|
24
|
+
export declare function keyId(key: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Env-var-backed key store for local/dev and stdio single-customer processes.
|
|
27
|
+
*
|
|
28
|
+
* FINCTL_API_KEY active key (required)
|
|
29
|
+
* FINCTL_CUSTOMER_ID customer the key maps to (default "local")
|
|
30
|
+
* FINCTL_ACCOUNT_IDS comma-separated AWS account scope (optional)
|
|
31
|
+
*
|
|
32
|
+
* Zero-downtime rotation grace — the previous key keeps working until its expiry
|
|
33
|
+
* (default 5 minutes after rotation):
|
|
34
|
+
* FINCTL_API_KEY_PREVIOUS prior key, still accepted during grace
|
|
35
|
+
* FINCTL_API_KEY_PREVIOUS_EXPIRES_AT epoch ms after which the prior key is rejected
|
|
36
|
+
*/
|
|
37
|
+
export declare class EnvKeyStore implements KeyStore {
|
|
38
|
+
private readonly current;
|
|
39
|
+
private readonly previous;
|
|
40
|
+
private readonly previousExpiresAt;
|
|
41
|
+
private readonly customerId;
|
|
42
|
+
private readonly accountIds;
|
|
43
|
+
constructor(env?: NodeJS.ProcessEnv);
|
|
44
|
+
/** True if at least one key is configured (used to fail fast at startup). */
|
|
45
|
+
isConfigured(): boolean;
|
|
46
|
+
validate(presentedKey: string): Promise<KeyValidation | null>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Derive a stable, non-secret identifier from a key. Logging the raw key would
|
|
4
|
+
* leak a credential; this hash prefix is safe to log and rate-limit on.
|
|
5
|
+
*/
|
|
6
|
+
export function keyId(key) {
|
|
7
|
+
return "k_" + createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
8
|
+
}
|
|
9
|
+
/** Length-safe, timing-safe key comparison. */
|
|
10
|
+
function secretEquals(a, b) {
|
|
11
|
+
const ab = Buffer.from(a);
|
|
12
|
+
const bb = Buffer.from(b);
|
|
13
|
+
if (ab.length !== bb.length)
|
|
14
|
+
return false;
|
|
15
|
+
return timingSafeEqual(ab, bb);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Env-var-backed key store for local/dev and stdio single-customer processes.
|
|
19
|
+
*
|
|
20
|
+
* FINCTL_API_KEY active key (required)
|
|
21
|
+
* FINCTL_CUSTOMER_ID customer the key maps to (default "local")
|
|
22
|
+
* FINCTL_ACCOUNT_IDS comma-separated AWS account scope (optional)
|
|
23
|
+
*
|
|
24
|
+
* Zero-downtime rotation grace — the previous key keeps working until its expiry
|
|
25
|
+
* (default 5 minutes after rotation):
|
|
26
|
+
* FINCTL_API_KEY_PREVIOUS prior key, still accepted during grace
|
|
27
|
+
* FINCTL_API_KEY_PREVIOUS_EXPIRES_AT epoch ms after which the prior key is rejected
|
|
28
|
+
*/
|
|
29
|
+
export class EnvKeyStore {
|
|
30
|
+
current;
|
|
31
|
+
previous;
|
|
32
|
+
previousExpiresAt;
|
|
33
|
+
customerId;
|
|
34
|
+
accountIds;
|
|
35
|
+
constructor(env = process.env) {
|
|
36
|
+
this.current = env.FINCTL_API_KEY?.trim() || undefined;
|
|
37
|
+
this.previous = env.FINCTL_API_KEY_PREVIOUS?.trim() || undefined;
|
|
38
|
+
this.previousExpiresAt = Number(env.FINCTL_API_KEY_PREVIOUS_EXPIRES_AT) || 0;
|
|
39
|
+
this.customerId = env.FINCTL_CUSTOMER_ID?.trim() || "local";
|
|
40
|
+
this.accountIds = (env.FINCTL_ACCOUNT_IDS ?? "")
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
/** True if at least one key is configured (used to fail fast at startup). */
|
|
46
|
+
isConfigured() {
|
|
47
|
+
return Boolean(this.current);
|
|
48
|
+
}
|
|
49
|
+
async validate(presentedKey) {
|
|
50
|
+
const scope = { customerId: this.customerId, accountIds: this.accountIds };
|
|
51
|
+
if (this.current && secretEquals(presentedKey, this.current)) {
|
|
52
|
+
return { keyId: keyId(this.current), ...scope };
|
|
53
|
+
}
|
|
54
|
+
if (this.previous &&
|
|
55
|
+
this.previousExpiresAt > Date.now() &&
|
|
56
|
+
secretEquals(presentedKey, this.previous)) {
|
|
57
|
+
return { keyId: keyId(this.previous), ...scope };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAuB1D;;;GAGG;AACH,MAAM,UAAU,KAAK,CAAC,GAAW;IAC/B,OAAO,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAC5E,CAAC;AAED,+CAA+C;AAC/C,SAAS,YAAY,CAAC,CAAS,EAAE,CAAS;IACxC,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC1B,IAAI,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1C,OAAO,eAAe,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,WAAW;IACL,OAAO,CAAqB;IAC5B,QAAQ,CAAqB;IAC7B,iBAAiB,CAAS;IAC1B,UAAU,CAAS;IACnB,UAAU,CAAW;IAEtC,YAAY,MAAyB,OAAO,CAAC,GAAG;QAC9C,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC,cAAc,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;QACvD,IAAI,CAAC,QAAQ,GAAG,GAAG,CAAC,uBAAuB,EAAE,IAAI,EAAE,IAAI,SAAS,CAAC;QACjE,IAAI,CAAC,iBAAiB,GAAG,MAAM,CAAC,GAAG,CAAC,kCAAkC,CAAC,IAAI,CAAC,CAAC;QAC7E,IAAI,CAAC,UAAU,GAAG,GAAG,CAAC,kBAAkB,EAAE,IAAI,EAAE,IAAI,OAAO,CAAC;QAC5D,IAAI,CAAC,UAAU,GAAG,CAAC,GAAG,CAAC,kBAAkB,IAAI,EAAE,CAAC;aAC7C,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;aACpB,MAAM,CAAC,OAAO,CAAC,CAAC;IACrB,CAAC;IAED,6EAA6E;IAC7E,YAAY;QACV,OAAO,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,YAAoB;QACjC,MAAM,KAAK,GAAG,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC;QAE3E,IAAI,IAAI,CAAC,OAAO,IAAI,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7D,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC;QAClD,CAAC;QACD,IACE,IAAI,CAAC,QAAQ;YACb,IAAI,CAAC,iBAAiB,GAAG,IAAI,CAAC,GAAG,EAAE;YACnC,YAAY,CAAC,YAAY,EAAE,IAAI,CAAC,QAAQ,CAAC,EACzC,CAAC;YACD,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG,KAAK,EAAE,CAAC;QACnD,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;CACF"}
|