@invokable/server 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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/checkpoint-routes.d.ts +40 -0
- package/dist/checkpoint-routes.d.ts.map +1 -0
- package/dist/checkpoint-routes.js +112 -0
- package/dist/checkpoint-routes.js.map +1 -0
- package/dist/checkpoints.d.ts +93 -0
- package/dist/checkpoints.d.ts.map +1 -0
- package/dist/checkpoints.js +179 -0
- package/dist/checkpoints.js.map +1 -0
- package/dist/handler.d.ts +48 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +221 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +10 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +90 -0
- package/dist/node.js.map +1 -0
- package/dist/stable-json.d.ts +11 -0
- package/dist/stable-json.d.ts.map +1 -0
- package/dist/stable-json.js +41 -0
- package/dist/stable-json.js.map +1 -0
- package/dist/store.d.ts +48 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +38 -0
- package/dist/store.js.map +1 -0
- package/dist/tokens.d.ts +11 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +47 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Invokable
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @invokable/server
|
|
2
|
+
|
|
3
|
+
Self-hostable device-code auth for [invokable](https://github.com/beinvokable/invokable)
|
|
4
|
+
tools. Implements the five endpoints `@invokable/core`'s `login` command talks to.
|
|
5
|
+
|
|
6
|
+
## Use
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
import { invokableAuth, memoryStore } from '@invokable/server';
|
|
11
|
+
import { nodeListener } from '@invokable/server/node';
|
|
12
|
+
|
|
13
|
+
const handler = invokableAuth({
|
|
14
|
+
store: memoryStore(), // swap for a durable store
|
|
15
|
+
tokenPrefix: 'mtl',
|
|
16
|
+
requireSession: (request) => getUserFromCookie(request), // your login
|
|
17
|
+
approvePage: ({ device, user }) => renderMyBrandedPage(device, user),
|
|
18
|
+
tokenTtl: null, // long-lived; revocation only
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
createServer(nodeListener(handler)).listen(8787);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`invokableAuth` returns a fetch-style `(Request) => Promise<Response | null>`.
|
|
25
|
+
`null` means "not my route", so it composes with your own routing. Hono, Deno and
|
|
26
|
+
workers can call it directly; `./node` adapts it to `node:http` and Express
|
|
27
|
+
(`expressMiddleware`).
|
|
28
|
+
|
|
29
|
+
A runnable version is in
|
|
30
|
+
[`examples/server`](../../examples/server/server.mjs).
|
|
31
|
+
|
|
32
|
+
## Endpoints
|
|
33
|
+
|
|
34
|
+
| Method | Path | Purpose |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `POST` | `/device/start` | Issues a device code + user code |
|
|
37
|
+
| `GET` | `/device?code=…` | The approval page a human sees |
|
|
38
|
+
| `POST` | `/device/approve` | Records the decision — **requires a session** |
|
|
39
|
+
| `POST` | `/device/token` | Polled by the CLI until approved |
|
|
40
|
+
| `GET` | `/cli/whoami` | Identity behind the bearer token |
|
|
41
|
+
| `POST` | `/cli/logout` | Revokes the bearer token |
|
|
42
|
+
|
|
43
|
+
## What it does about security
|
|
44
|
+
|
|
45
|
+
- **Tokens are stored hashed.** `<prefix>_<32 chars base62>` is returned to the
|
|
46
|
+
client once; only a SHA-256 digest is persisted. A dump of the store does not
|
|
47
|
+
yield working credentials.
|
|
48
|
+
- **Device codes are single-use.** Issuing a token marks the device `consumed`,
|
|
49
|
+
so a leaked device code cannot be replayed into a second credential.
|
|
50
|
+
- **Polling is rate-limited.** Polling faster than the advertised interval gets
|
|
51
|
+
`slow_down`, not a token.
|
|
52
|
+
- **Approval requires a session.** `requireSession` returning null means nothing
|
|
53
|
+
can be approved, and the endpoint answers 401.
|
|
54
|
+
- **User codes avoid ambiguous characters** (no `0/O/1/I/L`): they get read aloud
|
|
55
|
+
and typed by hand.
|
|
56
|
+
|
|
57
|
+
## What it does *not* do — read before deploying
|
|
58
|
+
|
|
59
|
+
**Device-code phishing is the attack this flow is exposed to.** An attacker
|
|
60
|
+
starts a login on their own machine, sends you the code, and asks you to approve
|
|
61
|
+
it; the token is then issued to *them*, against *your* identity. Nothing in the
|
|
62
|
+
protocol prevents this — the defence is the user recognising a login they did not
|
|
63
|
+
start.
|
|
64
|
+
|
|
65
|
+
The default approval page therefore shows the tool, version, and hostname the
|
|
66
|
+
device reported, and says plainly that you should only approve a login you just
|
|
67
|
+
started. **If you supply your own `approvePage`, keep that.** A page that shows
|
|
68
|
+
only a code and an Approve button is materially less safe.
|
|
69
|
+
|
|
70
|
+
Also not handled here, and yours to add:
|
|
71
|
+
|
|
72
|
+
- **CSRF on `/device/approve`.** The handler checks a session, not a CSRF token.
|
|
73
|
+
If you serve the default form-based page from a cookie-authenticated origin,
|
|
74
|
+
add your framework's CSRF protection.
|
|
75
|
+
- **Rate limiting on `/device/start`.** Nothing stops an attacker minting device
|
|
76
|
+
codes in bulk.
|
|
77
|
+
- **Durable storage.** `memoryStore()` loses every token on restart. It is for
|
|
78
|
+
development and tests. A Postgres store is on the roadmap.
|
|
79
|
+
- **Audit logging.** The store records who approved what and when; surfacing it
|
|
80
|
+
is the host application's job.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CheckpointVerifier, type CheckpointFailure } from './checkpoints.js';
|
|
2
|
+
export interface CheckpointRoutesOptions {
|
|
3
|
+
verifier: CheckpointVerifier;
|
|
4
|
+
/**
|
|
5
|
+
* Identifies the caller from the request, so a fingerprint issued to one
|
|
6
|
+
* subject cannot be verified by another. Returning null still allows
|
|
7
|
+
* issuance; supply it in production.
|
|
8
|
+
*/
|
|
9
|
+
identify?: (request: Request) => string | null | Promise<string | null>;
|
|
10
|
+
}
|
|
11
|
+
/** Maps a verification failure onto the 409 the client turns into exit 12. */
|
|
12
|
+
export declare function staleResponse(reason: CheckpointFailure, retryCommand?: string, detail?: string): Response;
|
|
13
|
+
/**
|
|
14
|
+
* `POST /checkpoints` (issue) and `POST /checkpoints/verify` (non-consuming
|
|
15
|
+
* check). Returns null for paths it does not own.
|
|
16
|
+
*
|
|
17
|
+
* Verification here is deliberately NOT consuming: the approval is burned by
|
|
18
|
+
* `verifyCheckpoint` on the action request itself, so the approval is spent by
|
|
19
|
+
* the operation it authorised rather than by a preflight call.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkpointRoutes(options: CheckpointRoutesOptions): (request: Request) => Promise<Response | null>;
|
|
22
|
+
export interface VerifyCheckpointOptions {
|
|
23
|
+
verifier: CheckpointVerifier;
|
|
24
|
+
/**
|
|
25
|
+
* Which requests require an approval. Defaults to every non-GET request,
|
|
26
|
+
* which is safe-by-default but usually too broad — narrow it to the routes
|
|
27
|
+
* that actually spend.
|
|
28
|
+
*/
|
|
29
|
+
requiresApproval?: (request: Request) => boolean;
|
|
30
|
+
/** Recomputes the subject for the incoming request, to bind gate to target. */
|
|
31
|
+
subjectFor?: (request: Request) => string | Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Middleware that performs steps 1-4 of spec 5.8 on the action request, burning
|
|
35
|
+
* the approval as the action is authorised.
|
|
36
|
+
*
|
|
37
|
+
* Returns a 409 Response to reject, or null to let the request proceed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyCheckpoint(options: VerifyCheckpointOptions): (request: Request) => Promise<Response | null>;
|
|
40
|
+
//# sourceMappingURL=checkpoint-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoint-routes.d.ts","sourceRoot":"","sources":["../src/checkpoint-routes.ts"],"names":[],"mappings":"AACA,OAAO,EACL,kBAAkB,EAGlB,KAAK,iBAAiB,EACvB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACzE;AAkBD,8EAA8E;AAC9E,wBAAgB,aAAa,CAC3B,MAAM,EAAE,iBAAiB,EACzB,YAAY,CAAC,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,MAAM,GACd,QAAQ,CAaV;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,IAGlC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CA+CzE;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC;IACjD,+EAA+E;IAC/E,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC7D;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,IAOnC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CA0BxE"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { stableStringify } from './stable-json.js';
|
|
2
|
+
import { CheckpointVerifier, hashSummary, parseCheckpointHeader, } from './checkpoints.js';
|
|
3
|
+
function json(body, status = 200) {
|
|
4
|
+
return new Response(JSON.stringify(body), {
|
|
5
|
+
status,
|
|
6
|
+
headers: { 'content-type': 'application/json' },
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
const STALE_MESSAGE = {
|
|
10
|
+
not_found: 'No approval matches that fingerprint.',
|
|
11
|
+
gate_mismatch: 'That approval was issued for a different gate.',
|
|
12
|
+
subject_mismatch: 'That approval was issued for a different target.',
|
|
13
|
+
mismatch: 'The plan changed since this approval was issued.',
|
|
14
|
+
expired: 'This approval has expired.',
|
|
15
|
+
consumed: 'This approval was already used.',
|
|
16
|
+
};
|
|
17
|
+
/** Maps a verification failure onto the 409 the client turns into exit 12. */
|
|
18
|
+
export function staleResponse(reason, retryCommand, detail) {
|
|
19
|
+
return json({
|
|
20
|
+
error: 'checkpoint_stale',
|
|
21
|
+
code: 'checkpoint_stale',
|
|
22
|
+
// The detail names a wiring mistake precisely; without it a subject
|
|
23
|
+
// mismatch is indistinguishable from a forged fingerprint.
|
|
24
|
+
message: detail ? `${STALE_MESSAGE[reason]} (${detail})` : STALE_MESSAGE[reason],
|
|
25
|
+
reason,
|
|
26
|
+
remediation: retryCommand ?? 'Re-run the command without --approve to get a fresh plan.',
|
|
27
|
+
}, 409);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `POST /checkpoints` (issue) and `POST /checkpoints/verify` (non-consuming
|
|
31
|
+
* check). Returns null for paths it does not own.
|
|
32
|
+
*
|
|
33
|
+
* Verification here is deliberately NOT consuming: the approval is burned by
|
|
34
|
+
* `verifyCheckpoint` on the action request itself, so the approval is spent by
|
|
35
|
+
* the operation it authorised rather than by a preflight call.
|
|
36
|
+
*/
|
|
37
|
+
export function checkpointRoutes(options) {
|
|
38
|
+
const { verifier, identify } = options;
|
|
39
|
+
return async function handle(request) {
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
42
|
+
const method = request.method.toUpperCase();
|
|
43
|
+
if (path.endsWith('/checkpoints') && method === 'POST') {
|
|
44
|
+
const body = (await request.json().catch(() => ({})));
|
|
45
|
+
const gate = String(body['gate'] ?? '');
|
|
46
|
+
if (!gate)
|
|
47
|
+
return json({ error: 'usage', message: '`gate` is required.' }, 400);
|
|
48
|
+
const subject = String(body['subject'] ?? '');
|
|
49
|
+
const issuedTo = (await identify?.(request)) ?? undefined;
|
|
50
|
+
const record = await verifier.issue({
|
|
51
|
+
gate,
|
|
52
|
+
subject,
|
|
53
|
+
summaryHash: hashSummary(stableStringify(body['summary'] ?? null)),
|
|
54
|
+
...(issuedTo !== undefined ? { issuedTo } : {}),
|
|
55
|
+
});
|
|
56
|
+
return json({
|
|
57
|
+
fingerprint: record.fingerprint,
|
|
58
|
+
gate: record.gate,
|
|
59
|
+
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (path.endsWith('/checkpoints/verify') && method === 'POST') {
|
|
63
|
+
const body = (await request.json().catch(() => ({})));
|
|
64
|
+
const gate = String(body['gate'] ?? '');
|
|
65
|
+
const subject = String(body['subject'] ?? '');
|
|
66
|
+
const fingerprint = String(body['fingerprint'] ?? '');
|
|
67
|
+
const expected = body['summaryHash'];
|
|
68
|
+
const result = await verifier.verify({
|
|
69
|
+
gate,
|
|
70
|
+
subject,
|
|
71
|
+
fingerprint,
|
|
72
|
+
...(typeof expected === 'string' ? { expectedSummaryHash: expected } : {}),
|
|
73
|
+
});
|
|
74
|
+
if (!result.ok)
|
|
75
|
+
return staleResponse(result.reason ?? 'not_found', undefined, result.detail);
|
|
76
|
+
return json({ valid: true, gate, expiresAt: new Date(result.record.expiresAt).toISOString() });
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Middleware that performs steps 1-4 of spec 5.8 on the action request, burning
|
|
83
|
+
* the approval as the action is authorised.
|
|
84
|
+
*
|
|
85
|
+
* Returns a 409 Response to reject, or null to let the request proceed.
|
|
86
|
+
*/
|
|
87
|
+
export function verifyCheckpoint(options) {
|
|
88
|
+
const { verifier, requiresApproval = (req) => req.method.toUpperCase() !== 'GET', subjectFor, } = options;
|
|
89
|
+
return async function check(request) {
|
|
90
|
+
if (!requiresApproval(request))
|
|
91
|
+
return null;
|
|
92
|
+
const header = parseCheckpointHeader(request.headers.get('x-invokable-checkpoint'));
|
|
93
|
+
if (!header) {
|
|
94
|
+
return json({
|
|
95
|
+
error: 'checkpoint_required',
|
|
96
|
+
code: 'checkpoint_stale',
|
|
97
|
+
message: 'This action requires an approval fingerprint.',
|
|
98
|
+
remediation: 'Re-run the command without --approve to get a fresh plan.',
|
|
99
|
+
}, 409);
|
|
100
|
+
}
|
|
101
|
+
const subject = subjectFor ? await subjectFor(request) : '';
|
|
102
|
+
const result = await verifier.consume({
|
|
103
|
+
gate: header.gate,
|
|
104
|
+
subject,
|
|
105
|
+
fingerprint: header.fingerprint,
|
|
106
|
+
});
|
|
107
|
+
if (!result.ok)
|
|
108
|
+
return staleResponse(result.reason ?? 'not_found', undefined, result.detail);
|
|
109
|
+
return null;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=checkpoint-routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoint-routes.js","sourceRoot":"","sources":["../src/checkpoint-routes.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface FingerprintInput {
|
|
2
|
+
gate: string;
|
|
3
|
+
subject: string;
|
|
4
|
+
summaryHash: string;
|
|
5
|
+
issuedAt: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 16 base32 characters = 80 bits. Sufficient because a fingerprint is scoped to
|
|
9
|
+
* (gate, subject), is consumed once, and expires — it is not a bearer secret.
|
|
10
|
+
*/
|
|
11
|
+
export declare function computeFingerprint(secret: string, input: FingerprintInput): string;
|
|
12
|
+
export declare function hashSummary(canonical: string): string;
|
|
13
|
+
export interface CheckpointRecord {
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
gate: string;
|
|
16
|
+
subject: string;
|
|
17
|
+
summaryHash: string;
|
|
18
|
+
issuedAt: number;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
consumed: boolean;
|
|
21
|
+
consumedAt?: number;
|
|
22
|
+
/** Whoever the token belonged to when the checkpoint was issued. */
|
|
23
|
+
issuedTo?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckpointStore {
|
|
26
|
+
createCheckpoint(record: CheckpointRecord): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Looks up by fingerprint alone. The gate and subject are compared by the
|
|
29
|
+
* verifier rather than folded into the query, so that an approval issued for
|
|
30
|
+
* a different target can be reported as such instead of as "unknown".
|
|
31
|
+
*/
|
|
32
|
+
findCheckpoint(fingerprint: string): Promise<CheckpointRecord | null>;
|
|
33
|
+
consumeCheckpoint(fingerprint: string, at: number): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
export declare function memoryCheckpointStore(): CheckpointStore & {
|
|
36
|
+
_records: Map<string, CheckpointRecord>;
|
|
37
|
+
};
|
|
38
|
+
export type CheckpointFailure = 'not_found' | 'gate_mismatch' | 'subject_mismatch' | 'mismatch' | 'expired' | 'consumed';
|
|
39
|
+
export interface VerifyResult {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
reason?: CheckpointFailure;
|
|
42
|
+
record?: CheckpointRecord;
|
|
43
|
+
/** Extra context for a mismatch, safe to show a developer. */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface CheckpointVerifierOptions {
|
|
47
|
+
secret: string;
|
|
48
|
+
/** Honoured for 24h after rotation, so in-flight approvals keep working. */
|
|
49
|
+
previousSecret?: string;
|
|
50
|
+
store: CheckpointStore;
|
|
51
|
+
/** Default 24 hours (spec 5.8). */
|
|
52
|
+
ttlMs?: number;
|
|
53
|
+
now?: () => number;
|
|
54
|
+
}
|
|
55
|
+
export declare class CheckpointVerifier {
|
|
56
|
+
private readonly secret;
|
|
57
|
+
private readonly previousSecret;
|
|
58
|
+
private readonly store;
|
|
59
|
+
private readonly ttlMs;
|
|
60
|
+
private readonly now;
|
|
61
|
+
constructor(options: CheckpointVerifierOptions);
|
|
62
|
+
issue(input: {
|
|
63
|
+
gate: string;
|
|
64
|
+
subject: string;
|
|
65
|
+
summaryHash: string;
|
|
66
|
+
issuedTo?: string;
|
|
67
|
+
}): Promise<CheckpointRecord>;
|
|
68
|
+
/**
|
|
69
|
+
* Steps 1-3 of spec 5.8: the record exists, the MAC still recomputes from
|
|
70
|
+
* stored state, and it is neither expired nor already consumed.
|
|
71
|
+
*
|
|
72
|
+
* `expectedSummaryHash` is the caller's view of current state. Supplying it
|
|
73
|
+
* is what makes an approval *stale* when the plan changed underneath.
|
|
74
|
+
*/
|
|
75
|
+
verify(input: {
|
|
76
|
+
gate: string;
|
|
77
|
+
subject: string;
|
|
78
|
+
fingerprint: string;
|
|
79
|
+
expectedSummaryHash?: string;
|
|
80
|
+
}): Promise<VerifyResult>;
|
|
81
|
+
/** Step 4: verify, then burn. Returns false if it could not be consumed. */
|
|
82
|
+
consume(input: {
|
|
83
|
+
gate: string;
|
|
84
|
+
subject: string;
|
|
85
|
+
fingerprint: string;
|
|
86
|
+
expectedSummaryHash?: string;
|
|
87
|
+
}): Promise<VerifyResult>;
|
|
88
|
+
}
|
|
89
|
+
export declare function parseCheckpointHeader(value: string | null): {
|
|
90
|
+
gate: string;
|
|
91
|
+
fingerprint: string;
|
|
92
|
+
} | null;
|
|
93
|
+
//# sourceMappingURL=checkpoints.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoints.d.ts","sourceRoot":"","sources":["../src/checkpoints.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Server-issued checkpoint fingerprints (spec 5.8).
|
|
4
|
+
*
|
|
5
|
+
* The fingerprint is an HMAC, not a hash of the summary, so it cannot be
|
|
6
|
+
* computed by anyone who merely saw the summary — including the agent. What it
|
|
7
|
+
* buys is freshness (an approval cannot outlive the state it described) and
|
|
8
|
+
* single use (an approval cannot be replayed). It does not contain a hostile
|
|
9
|
+
* agent: see the security note in the package README.
|
|
10
|
+
*/
|
|
11
|
+
const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
12
|
+
function base32(buffer) {
|
|
13
|
+
let bits = 0;
|
|
14
|
+
let value = 0;
|
|
15
|
+
let out = '';
|
|
16
|
+
for (const byte of buffer) {
|
|
17
|
+
value = (value << 8) | byte;
|
|
18
|
+
bits += 8;
|
|
19
|
+
while (bits >= 5) {
|
|
20
|
+
out += BASE32[(value >>> (bits - 5)) & 31];
|
|
21
|
+
bits -= 5;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (bits > 0)
|
|
25
|
+
out += BASE32[(value << (5 - bits)) & 31];
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 16 base32 characters = 80 bits. Sufficient because a fingerprint is scoped to
|
|
30
|
+
* (gate, subject), is consumed once, and expires — it is not a bearer secret.
|
|
31
|
+
*/
|
|
32
|
+
export function computeFingerprint(secret, input) {
|
|
33
|
+
const mac = createHmac('sha256', secret)
|
|
34
|
+
.update(`${input.gate}|${input.subject}|${input.summaryHash}|${input.issuedAt}`, 'utf8')
|
|
35
|
+
.digest();
|
|
36
|
+
return base32(mac).slice(0, 16);
|
|
37
|
+
}
|
|
38
|
+
export function hashSummary(canonical) {
|
|
39
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function constantTimeEqual(a, b) {
|
|
42
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
43
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
44
|
+
if (bufA.length !== bufB.length)
|
|
45
|
+
return false;
|
|
46
|
+
return timingSafeEqual(bufA, bufB);
|
|
47
|
+
}
|
|
48
|
+
export function memoryCheckpointStore() {
|
|
49
|
+
const records = new Map();
|
|
50
|
+
return {
|
|
51
|
+
_records: records,
|
|
52
|
+
async createCheckpoint(record) {
|
|
53
|
+
records.set(record.fingerprint, record);
|
|
54
|
+
},
|
|
55
|
+
async findCheckpoint(fingerprint) {
|
|
56
|
+
return records.get(fingerprint) ?? null;
|
|
57
|
+
},
|
|
58
|
+
async consumeCheckpoint(fingerprint, at) {
|
|
59
|
+
const found = records.get(fingerprint);
|
|
60
|
+
// Consumption must be atomic: two concurrent requests carrying the same
|
|
61
|
+
// fingerprint must not both succeed. A real store does this with a
|
|
62
|
+
// conditional update; the Map is single-threaded, so the check suffices.
|
|
63
|
+
if (!found || found.consumed)
|
|
64
|
+
return false;
|
|
65
|
+
records.set(fingerprint, { ...found, consumed: true, consumedAt: at });
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
71
|
+
export class CheckpointVerifier {
|
|
72
|
+
secret;
|
|
73
|
+
previousSecret;
|
|
74
|
+
store;
|
|
75
|
+
ttlMs;
|
|
76
|
+
now;
|
|
77
|
+
constructor(options) {
|
|
78
|
+
if (!options.secret) {
|
|
79
|
+
throw new TypeError('CheckpointVerifier requires a secret.');
|
|
80
|
+
}
|
|
81
|
+
this.secret = options.secret;
|
|
82
|
+
this.previousSecret = options.previousSecret;
|
|
83
|
+
this.store = options.store;
|
|
84
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
85
|
+
this.now = options.now ?? (() => Date.now());
|
|
86
|
+
}
|
|
87
|
+
async issue(input) {
|
|
88
|
+
const issuedAt = this.now();
|
|
89
|
+
const fingerprint = computeFingerprint(this.secret, {
|
|
90
|
+
gate: input.gate,
|
|
91
|
+
subject: input.subject,
|
|
92
|
+
summaryHash: input.summaryHash,
|
|
93
|
+
issuedAt,
|
|
94
|
+
});
|
|
95
|
+
const record = {
|
|
96
|
+
fingerprint,
|
|
97
|
+
gate: input.gate,
|
|
98
|
+
subject: input.subject,
|
|
99
|
+
summaryHash: input.summaryHash,
|
|
100
|
+
issuedAt,
|
|
101
|
+
expiresAt: issuedAt + this.ttlMs,
|
|
102
|
+
consumed: false,
|
|
103
|
+
...(input.issuedTo !== undefined ? { issuedTo: input.issuedTo } : {}),
|
|
104
|
+
};
|
|
105
|
+
await this.store.createCheckpoint(record);
|
|
106
|
+
return record;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Steps 1-3 of spec 5.8: the record exists, the MAC still recomputes from
|
|
110
|
+
* stored state, and it is neither expired nor already consumed.
|
|
111
|
+
*
|
|
112
|
+
* `expectedSummaryHash` is the caller's view of current state. Supplying it
|
|
113
|
+
* is what makes an approval *stale* when the plan changed underneath.
|
|
114
|
+
*/
|
|
115
|
+
async verify(input) {
|
|
116
|
+
const record = await this.store.findCheckpoint(input.fingerprint);
|
|
117
|
+
if (!record)
|
|
118
|
+
return { ok: false, reason: 'not_found' };
|
|
119
|
+
// Reported separately from "not found". A gate or subject that disagrees
|
|
120
|
+
// between the CLI and the middleware is a wiring mistake, and calling it an
|
|
121
|
+
// unknown fingerprint sends the integrator looking in the wrong place.
|
|
122
|
+
if (record.gate !== input.gate) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
reason: 'gate_mismatch',
|
|
126
|
+
record,
|
|
127
|
+
detail: `issued for gate "${record.gate}", presented for "${input.gate}"`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (record.subject !== input.subject) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: 'subject_mismatch',
|
|
134
|
+
record,
|
|
135
|
+
detail: `issued for subject ${JSON.stringify(record.subject)}, presented for ` +
|
|
136
|
+
`${JSON.stringify(input.subject)} — the \`subject\` passed to checkpoint() must ` +
|
|
137
|
+
'equal what `subjectFor` returns in verifyCheckpoint()',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const recomputes = [this.secret, this.previousSecret]
|
|
141
|
+
.filter((s) => Boolean(s))
|
|
142
|
+
.some((secret) => constantTimeEqual(computeFingerprint(secret, {
|
|
143
|
+
gate: record.gate,
|
|
144
|
+
subject: record.subject,
|
|
145
|
+
summaryHash: record.summaryHash,
|
|
146
|
+
issuedAt: record.issuedAt,
|
|
147
|
+
}), input.fingerprint));
|
|
148
|
+
if (!recomputes)
|
|
149
|
+
return { ok: false, reason: 'mismatch', record };
|
|
150
|
+
if (input.expectedSummaryHash !== undefined &&
|
|
151
|
+
!constantTimeEqual(record.summaryHash, input.expectedSummaryHash)) {
|
|
152
|
+
return { ok: false, reason: 'mismatch', record };
|
|
153
|
+
}
|
|
154
|
+
if (record.expiresAt <= this.now())
|
|
155
|
+
return { ok: false, reason: 'expired', record };
|
|
156
|
+
if (record.consumed)
|
|
157
|
+
return { ok: false, reason: 'consumed', record };
|
|
158
|
+
return { ok: true, record };
|
|
159
|
+
}
|
|
160
|
+
/** Step 4: verify, then burn. Returns false if it could not be consumed. */
|
|
161
|
+
async consume(input) {
|
|
162
|
+
const result = await this.verify(input);
|
|
163
|
+
if (!result.ok)
|
|
164
|
+
return result;
|
|
165
|
+
const consumed = await this.store.consumeCheckpoint(input.fingerprint, this.now());
|
|
166
|
+
if (!consumed)
|
|
167
|
+
return { ok: false, reason: 'consumed', ...(result.record ? { record: result.record } : {}) };
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export function parseCheckpointHeader(value) {
|
|
172
|
+
if (!value)
|
|
173
|
+
return null;
|
|
174
|
+
const at = value.lastIndexOf('@');
|
|
175
|
+
if (at <= 0 || at === value.length - 1)
|
|
176
|
+
return null;
|
|
177
|
+
return { gate: value.slice(0, at), fingerprint: value.slice(at + 1) };
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=checkpoints.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkpoints.js","sourceRoot":"","sources":["../src/checkpoints.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AuthStore, DeviceRecord } from './store.js';
|
|
2
|
+
export interface SessionUser {
|
|
3
|
+
subject: string;
|
|
4
|
+
orgId?: string;
|
|
5
|
+
displayName?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ApprovePageContext {
|
|
8
|
+
userCode: string;
|
|
9
|
+
/** Null when no device is pending for that code. */
|
|
10
|
+
device: DeviceRecord | null;
|
|
11
|
+
user: SessionUser | null;
|
|
12
|
+
}
|
|
13
|
+
export interface InvokableAuthOptions {
|
|
14
|
+
store: AuthStore;
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the browser session on the approval page. Return null for a signed
|
|
17
|
+
* -out visitor; the handler will not approve anything without a user.
|
|
18
|
+
*/
|
|
19
|
+
requireSession: (request: Request) => SessionUser | null | Promise<SessionUser | null>;
|
|
20
|
+
/** Renders the branded approval page. A plain default is used when omitted. */
|
|
21
|
+
approvePage?: (ctx: ApprovePageContext) => string | Promise<string>;
|
|
22
|
+
/** Token prefix, e.g. `mtl`. Appears in the credential and in `doctor`. */
|
|
23
|
+
tokenPrefix?: string;
|
|
24
|
+
/** Milliseconds; `null` means long-lived with revocation only. */
|
|
25
|
+
tokenTtl?: number | null;
|
|
26
|
+
/** How long a device code stays valid. Default 15 minutes. */
|
|
27
|
+
deviceCodeTtlMs?: number;
|
|
28
|
+
/** Minimum seconds between polls before answering `slow_down`. Default 5. */
|
|
29
|
+
pollIntervalSeconds?: number;
|
|
30
|
+
/** Injectable clock, for tests. */
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The five device-flow endpoints of spec 5.4, as a fetch-style handler:
|
|
35
|
+
*
|
|
36
|
+
* POST /device/start → device + user code
|
|
37
|
+
* GET /device?code=… → approval page
|
|
38
|
+
* POST /device/approve → records the user's decision (session required)
|
|
39
|
+
* POST /device/token → polled by the CLI until approved
|
|
40
|
+
* POST /cli/logout → revokes the bearer token
|
|
41
|
+
* GET /cli/whoami → identity behind the bearer token
|
|
42
|
+
*
|
|
43
|
+
* Returns null for paths it does not own, so a host application can mount it
|
|
44
|
+
* alongside its own routes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function invokableAuth(options: InvokableAuthOptions): (request: Request) => Promise<Response | null>;
|
|
47
|
+
export type InvokableAuthHandler = ReturnType<typeof invokableAuth>;
|
|
48
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,SAAS,EAAE,YAAY,EAAe,MAAM,YAAY,CAAC;AAEvE,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAC5B,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,WAAW,GAAG,IAAI,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACvF,+EAA+E;IAC/E,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACpE,2EAA2E;IAC3E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6EAA6E;IAC7E,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mCAAmC;IACnC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AA6DD;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,IAsB5B,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAoJzE;AAED,MAAM,MAAM,oBAAoB,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC"}
|