toolcraft 0.0.147 → 0.0.149
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 +1 -0
- package/composition.json +2 -2
- package/dist/composition.json +2 -2
- package/dist/http-hosted-oauth.compile-check.js +6 -0
- package/dist/http-hosted-oauth.d.ts +3 -1
- package/dist/http-hosted-oauth.js +5 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +7 -0
- package/dist/mcp.js +2 -1
- package/dist/testing/hosted-oauth-storage.d.ts +6 -0
- package/dist/testing/hosted-oauth-storage.js +140 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/node_modules/mcp-oauth-server/dist/index.d.ts +3 -1
- package/node_modules/mcp-oauth-server/dist/index.js +25 -4
- package/node_modules/tiny-stdio-mcp-server/dist/composition.json +1 -1
- package/node_modules/toolcraft-schema/package.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -583,6 +583,7 @@ Toolcraft configuration is code-first. Use `defineCommand(config)` and `defineGr
|
|
|
583
583
|
- `positional?: string[]` — parameter names mapped from CLI argv order.
|
|
584
584
|
- `params: S.Object(...)` — input schema from `toolcraft-schema`.
|
|
585
585
|
- `result?: S.Object(...)` — structured result schema for MCP `outputSchema`; must be a root object.
|
|
586
|
+
- `mcpResult?: (result) => Record<string, unknown>` — optional MCP-only projection applied before `result` validation; CLI and SDK still return the raw handler result. Requires `result`.
|
|
586
587
|
- `secrets?: Record<string, { env: string; description?: string; optional?: boolean }>`
|
|
587
588
|
- `scope?: Array<"cli" | "mcp" | "sdk">` — defaults to `["cli", "sdk"]`.
|
|
588
589
|
- `confirm?: boolean` — deprecated CLI-only TTY confirmation; use `humanInLoop` instead. Cannot be combined with `humanInLoop`.
|
package/composition.json
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.149",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.149",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
package/dist/composition.json
CHANGED
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"name": "toolcraft",
|
|
116
|
-
"version": "0.0.
|
|
116
|
+
"version": "0.0.149",
|
|
117
117
|
"license": "MIT"
|
|
118
118
|
},
|
|
119
119
|
{
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"name": "toolcraft-schema",
|
|
126
|
-
"version": "0.0.
|
|
126
|
+
"version": "0.0.149",
|
|
127
127
|
"license": "MIT"
|
|
128
128
|
},
|
|
129
129
|
{
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { hostedOAuth } from "./http-hosted-oauth.js";
|
|
2
|
+
const ignoredRevocationObserver = async (grant) => {
|
|
3
|
+
const ignoredSubject = grant.subject;
|
|
4
|
+
void ignoredSubject;
|
|
5
|
+
};
|
|
6
|
+
void ignoredRevocationObserver;
|
|
2
7
|
const ignoredHostedOAuth = hostedOAuth({
|
|
3
8
|
publicUrl: "https://calendar.example/mcp",
|
|
4
9
|
storage: ignoredStorage,
|
|
@@ -30,6 +35,7 @@ const ignoredHostedOAuth = hostedOAuth({
|
|
|
30
35
|
}
|
|
31
36
|
});
|
|
32
37
|
void ignoredHostedOAuth;
|
|
38
|
+
void ignoredHostedOAuth.assertProductionReady();
|
|
33
39
|
const ignoredRedirectHostedOAuth = hostedOAuth({
|
|
34
40
|
publicUrl: "https://calendar.example/mcp",
|
|
35
41
|
storage: ignoredStorage,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type JWK } from "jose";
|
|
2
|
-
import { type AuthorizationServerStore, type AuthorizationTransactionRecord, type OAuthAuthorizationServerSigningKey } from "mcp-oauth-server";
|
|
2
|
+
import { type AuthorizationServerStore, type AuthorizationGrantRecord, type AuthorizationTransactionRecord, type OAuthAuthorizationServerSigningKey } from "mcp-oauth-server";
|
|
3
3
|
import type { HttpAdditionalRequestHandler, TinyHttpMcpServerOAuthOptions } from "tiny-http-mcp-server/server";
|
|
4
4
|
export type HostedOAuthLoginFieldName = "email" | "password" | "apiKey" | (string & {});
|
|
5
5
|
export interface HostedOAuthLoginField {
|
|
@@ -33,6 +33,7 @@ export interface HostedOAuthStorage<TCredential = unknown> {
|
|
|
33
33
|
resolveSubject(providerName: string, accountId: string): Promise<string>;
|
|
34
34
|
healthCheck?(): Promise<void>;
|
|
35
35
|
cleanup?(now?: number): Promise<void>;
|
|
36
|
+
onGrantRevoked?(grant: AuthorizationGrantRecord): Promise<void> | void;
|
|
36
37
|
}
|
|
37
38
|
export interface HostedOAuthCredentialAccess<TCredential = unknown> {
|
|
38
39
|
read(): Promise<TCredential>;
|
|
@@ -105,6 +106,7 @@ export interface HostedOAuthConfiguration<TCredential = unknown, TServices exten
|
|
|
105
106
|
prepare(options?: {
|
|
106
107
|
production?: boolean;
|
|
107
108
|
}): Promise<PreparedHostedOAuth>;
|
|
109
|
+
assertProductionReady(): Promise<PreparedHostedOAuth>;
|
|
108
110
|
}
|
|
109
111
|
export declare function isHostedOAuthConfiguration(value: unknown): value is HostedOAuthConfiguration<unknown, object>;
|
|
110
112
|
export declare function hostedOAuth<TCredential = unknown, TServices extends object = object>(options: HostedOAuthOptions<TCredential, TServices>): HostedOAuthConfiguration<TCredential, TServices>;
|
|
@@ -85,6 +85,9 @@ export function hostedOAuth(options) {
|
|
|
85
85
|
issuer: new URL(publicUrl.origin),
|
|
86
86
|
scopes: this.advanced?.scopes ?? ["mcp", "offline_access"]
|
|
87
87
|
};
|
|
88
|
+
},
|
|
89
|
+
async assertProductionReady() {
|
|
90
|
+
return this.prepare({ production: true });
|
|
88
91
|
}
|
|
89
92
|
};
|
|
90
93
|
}
|
|
@@ -225,7 +228,8 @@ export async function prepareHostedOAuthRuntime(config) {
|
|
|
225
228
|
accessTokenTtlSeconds: config.advanced?.accessTokenTtlSeconds,
|
|
226
229
|
authorizationCodeTtlSeconds: config.advanced?.authorizationCodeTtlSeconds,
|
|
227
230
|
authorizationTransactionTtlSeconds: config.advanced?.authorizationTransactionTtlSeconds,
|
|
228
|
-
refreshTokenTtlSeconds: config.advanced?.refreshTokenTtlSeconds
|
|
231
|
+
refreshTokenTtlSeconds: config.advanced?.refreshTokenTtlSeconds,
|
|
232
|
+
onGrantRevoked: config.storage.onGrantRevoked
|
|
229
233
|
});
|
|
230
234
|
const requestHandler = async (request, response) => {
|
|
231
235
|
const url = new URL(request.url ?? "/", prepared.issuer);
|
package/dist/index.d.ts
CHANGED
|
@@ -123,6 +123,7 @@ export interface CommandConfig<TServices extends object, TParamsSchema extends O
|
|
|
123
123
|
positional?: string[];
|
|
124
124
|
params: TParamsSchema;
|
|
125
125
|
result?: ObjectSchema<any>;
|
|
126
|
+
mcpResult?: (result: TResult) => Record<string, unknown>;
|
|
126
127
|
secrets?: TSecrets;
|
|
127
128
|
scope?: Scope[];
|
|
128
129
|
confirm?: boolean;
|
|
@@ -140,7 +141,7 @@ export type StreamHandlerContext<TParamsSchema extends ObjectSchema<any>, TSecre
|
|
|
140
141
|
status(event: StreamStatusEvent): void;
|
|
141
142
|
refreshSecrets(): Promise<InferSecrets<TSecrets>>;
|
|
142
143
|
};
|
|
143
|
-
export interface StreamCommandConfig<TServices extends object, TParamsSchema extends ObjectSchema<any>, TSecrets extends SecretDeclarations | undefined, TEventSchema extends AnySchema> extends Omit<CommandConfig<TServices, TParamsSchema, TSecrets, AsyncIterable<Static<TEventSchema>>>, "handler" | "render" | "result" | "humanInLoop" | "confirm"> {
|
|
144
|
+
export interface StreamCommandConfig<TServices extends object, TParamsSchema extends ObjectSchema<any>, TSecrets extends SecretDeclarations | undefined, TEventSchema extends AnySchema> extends Omit<CommandConfig<TServices, TParamsSchema, TSecrets, AsyncIterable<Static<TEventSchema>>>, "handler" | "render" | "result" | "mcpResult" | "humanInLoop" | "confirm"> {
|
|
144
145
|
event: TEventSchema;
|
|
145
146
|
handler: (ctx: StreamHandlerContext<TParamsSchema, TSecrets, TServices>) => AsyncIterable<Static<TEventSchema>> | Promise<AsyncIterable<Static<TEventSchema>>>;
|
|
146
147
|
render?: Renderers<Static<TEventSchema>>;
|
|
@@ -157,6 +158,7 @@ export interface Command<TServices extends object = EmptyServices, TParamsSchema
|
|
|
157
158
|
positional: string[];
|
|
158
159
|
params: TParamsSchema;
|
|
159
160
|
result?: ObjectSchema<any>;
|
|
161
|
+
mcpResult?: (result: TResult) => Record<string, unknown>;
|
|
160
162
|
stream?: StreamDefinition<any>;
|
|
161
163
|
secrets: SecretDeclarations;
|
|
162
164
|
scope: Scope[];
|
package/dist/index.js
CHANGED
|
@@ -291,6 +291,9 @@ function resolveGroupScope(ownScope, inheritedScope) {
|
|
|
291
291
|
return cloneScope(ownScope ?? inheritedScope);
|
|
292
292
|
}
|
|
293
293
|
function createBaseCommand(config) {
|
|
294
|
+
if (config.mcpResult !== undefined && config.result === undefined) {
|
|
295
|
+
throw new ToolcraftBugError(`Command "${config.name}" defines mcpResult without a result schema.`);
|
|
296
|
+
}
|
|
294
297
|
const command = {
|
|
295
298
|
kind: "command",
|
|
296
299
|
name: config.name,
|
|
@@ -303,6 +306,7 @@ function createBaseCommand(config) {
|
|
|
303
306
|
positional: [...(config.positional ?? [])],
|
|
304
307
|
params: config.params,
|
|
305
308
|
result: config.result,
|
|
309
|
+
mcpResult: config.mcpResult,
|
|
306
310
|
stream: undefined,
|
|
307
311
|
secrets: cloneSecrets(config.secrets),
|
|
308
312
|
scope: resolveCommandScope(config.scope, undefined),
|
|
@@ -320,6 +324,7 @@ function createBaseCommand(config) {
|
|
|
320
324
|
hidden: config.hidden ?? false,
|
|
321
325
|
examples: cloneCommandExamples(config.examples),
|
|
322
326
|
result: config.result,
|
|
327
|
+
mcpResult: config.mcpResult,
|
|
323
328
|
humanInLoop: config.humanInLoop,
|
|
324
329
|
secrets: cloneSecrets(config.secrets),
|
|
325
330
|
requires: cloneRequires(config.requires),
|
|
@@ -376,6 +381,7 @@ function materializeCommand(command, inherited) {
|
|
|
376
381
|
positional: [...command.positional],
|
|
377
382
|
params: command.params,
|
|
378
383
|
result: internal.result,
|
|
384
|
+
mcpResult: internal.mcpResult,
|
|
379
385
|
stream: command.stream,
|
|
380
386
|
secrets: mergeSecrets(inherited.secrets, internal.secrets),
|
|
381
387
|
scope: resolveCommandScope(internal.scope, inherited.scope),
|
|
@@ -393,6 +399,7 @@ function materializeCommand(command, inherited) {
|
|
|
393
399
|
hidden: internal.hidden,
|
|
394
400
|
examples: cloneCommandExamples(internal.examples),
|
|
395
401
|
result: internal.result,
|
|
402
|
+
mcpResult: internal.mcpResult,
|
|
396
403
|
humanInLoop: internal.humanInLoop,
|
|
397
404
|
secrets: cloneSecrets(internal.secrets),
|
|
398
405
|
requires: cloneRequires(internal.requires),
|
package/dist/mcp.js
CHANGED
|
@@ -892,7 +892,8 @@ function createResolvedMCPServer(root, options, runtime = {}) {
|
|
|
892
892
|
return renderPendingApproval(result);
|
|
893
893
|
}
|
|
894
894
|
if (tool.resultSchema !== undefined) {
|
|
895
|
-
const
|
|
895
|
+
const mcpResult = tool.command.mcpResult === undefined ? result : tool.command.mcpResult(result);
|
|
896
|
+
const structuredContent = validateCommandResult(tool.resultSchema, mcpResult, casing);
|
|
896
897
|
return {
|
|
897
898
|
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
|
|
898
899
|
structuredContent
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HostedOAuthStorage } from "../http-hosted-oauth.js";
|
|
2
|
+
export interface HostedOAuthStorageConformanceOptions<TCredential> {
|
|
3
|
+
createStorage(): HostedOAuthStorage<TCredential> | Promise<HostedOAuthStorage<TCredential>>;
|
|
4
|
+
credentials: readonly [TCredential, TCredential];
|
|
5
|
+
}
|
|
6
|
+
export declare function verifyHostedOAuthStorage<TCredential>(options: HostedOAuthStorageConformanceOptions<TCredential>): Promise<void>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
function assert(condition, message) {
|
|
3
|
+
if (!condition)
|
|
4
|
+
throw new Error(`Hosted OAuth storage conformance failed: ${message}`);
|
|
5
|
+
}
|
|
6
|
+
export async function verifyHostedOAuthStorage(options) {
|
|
7
|
+
const storage = await options.createStorage();
|
|
8
|
+
const [firstCredential, secondCredential] = options.credentials;
|
|
9
|
+
const firstSubject = await storage.resolveSubject("provider", "account-a");
|
|
10
|
+
const repeatedSubject = await storage.resolveSubject("provider", "account-a");
|
|
11
|
+
const secondSubject = await storage.resolveSubject("provider", "account-b");
|
|
12
|
+
const otherProviderSubject = await storage.resolveSubject("other-provider", "account-a");
|
|
13
|
+
assert(firstSubject === repeatedSubject, "subject resolution must be stable");
|
|
14
|
+
assert(firstSubject !== secondSubject, "different provider accounts must resolve independently");
|
|
15
|
+
assert(firstSubject !== otherProviderSubject, "provider namespaces must resolve independently");
|
|
16
|
+
await storage.credentials.set(firstSubject, firstCredential);
|
|
17
|
+
assert(isDeepStrictEqual(await storage.credentials.get(firstSubject), firstCredential), "credentials must round-trip");
|
|
18
|
+
await storage.credentials.update(firstSubject, () => secondCredential);
|
|
19
|
+
assert(isDeepStrictEqual(await storage.credentials.get(firstSubject), secondCredential), "credential updates must persist");
|
|
20
|
+
let releaseFirstUpdate;
|
|
21
|
+
let markFirstUpdateStarted;
|
|
22
|
+
const firstUpdateStarted = new Promise((resolve) => {
|
|
23
|
+
markFirstUpdateStarted = resolve;
|
|
24
|
+
});
|
|
25
|
+
const releaseFirst = new Promise((resolve) => {
|
|
26
|
+
releaseFirstUpdate = resolve;
|
|
27
|
+
});
|
|
28
|
+
const firstUpdate = storage.credentials.update(firstSubject, async (current) => {
|
|
29
|
+
assert(isDeepStrictEqual(current, secondCredential), "credential updates must read current data");
|
|
30
|
+
markFirstUpdateStarted?.();
|
|
31
|
+
await releaseFirst;
|
|
32
|
+
return firstCredential;
|
|
33
|
+
});
|
|
34
|
+
await firstUpdateStarted;
|
|
35
|
+
let secondUpdateStarted = false;
|
|
36
|
+
const secondUpdate = storage.credentials.update(firstSubject, (current) => {
|
|
37
|
+
secondUpdateStarted = true;
|
|
38
|
+
assert(isDeepStrictEqual(current, firstCredential), "credential updates must execute atomically");
|
|
39
|
+
return secondCredential;
|
|
40
|
+
});
|
|
41
|
+
const updates = Promise.all([firstUpdate, secondUpdate]);
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
assert(!secondUpdateStarted, "credential updates must be serialized");
|
|
44
|
+
releaseFirstUpdate?.();
|
|
45
|
+
await updates;
|
|
46
|
+
assert(isDeepStrictEqual(await storage.credentials.get(firstSubject), secondCredential), "serialized credential updates must persist");
|
|
47
|
+
await storage.credentials.delete(firstSubject);
|
|
48
|
+
assert((await storage.credentials.get(firstSubject)) === undefined, "credential deletion must persist");
|
|
49
|
+
const transaction = {
|
|
50
|
+
id: "interaction-1",
|
|
51
|
+
clientId: "client-1",
|
|
52
|
+
redirectUri: "https://client.example/callback",
|
|
53
|
+
codeChallenge: "challenge",
|
|
54
|
+
resource: "https://resource.example/mcp",
|
|
55
|
+
scopes: ["mcp"],
|
|
56
|
+
createdAt: 1,
|
|
57
|
+
expiresAt: 2
|
|
58
|
+
};
|
|
59
|
+
await storage.interactions.set(transaction);
|
|
60
|
+
assert(isDeepStrictEqual(await storage.interactions.get(transaction.id), transaction), "interactions must round-trip");
|
|
61
|
+
await storage.interactions.delete(transaction.id);
|
|
62
|
+
assert((await storage.interactions.get(transaction.id)) === undefined, "interaction deletion must persist");
|
|
63
|
+
const firstKey = await storage.signingKey();
|
|
64
|
+
const secondKey = await storage.signingKey();
|
|
65
|
+
assert(firstKey.keyId === secondKey.keyId && isDeepStrictEqual(firstKey.publicJwk, secondKey.publicJwk), "signing keys must remain stable");
|
|
66
|
+
const restartedStorage = await options.createStorage();
|
|
67
|
+
if (storage.capabilities.stableKeys) {
|
|
68
|
+
const restartedKey = await restartedStorage.signingKey();
|
|
69
|
+
assert(firstKey.keyId === restartedKey.keyId &&
|
|
70
|
+
isDeepStrictEqual(firstKey.publicJwk, restartedKey.publicJwk), "stable signing keys must survive adapter restart");
|
|
71
|
+
}
|
|
72
|
+
if (storage.capabilities.durable) {
|
|
73
|
+
assert((await restartedStorage.resolveSubject("provider", "account-a")) === firstSubject, "durable subject resolution must survive adapter restart");
|
|
74
|
+
}
|
|
75
|
+
const store = storage.authorizationServer;
|
|
76
|
+
const client = { id: "client-1", redirectUris: ["https://client.example/callback"], createdAt: 1 };
|
|
77
|
+
await store.putClient(client);
|
|
78
|
+
assert(isDeepStrictEqual(await store.getClient(client.id), client), "clients must round-trip");
|
|
79
|
+
await store.putAuthorizationTransaction(transaction);
|
|
80
|
+
assert(isDeepStrictEqual(await store.takeAuthorizationTransaction(transaction.id), transaction) &&
|
|
81
|
+
(await store.takeAuthorizationTransaction(transaction.id)) === undefined, "authorization transactions must be consumed atomically");
|
|
82
|
+
const grant = {
|
|
83
|
+
id: "grant-1",
|
|
84
|
+
clientId: client.id,
|
|
85
|
+
subject: firstSubject,
|
|
86
|
+
resource: transaction.resource,
|
|
87
|
+
scopes: ["mcp", "offline_access"],
|
|
88
|
+
createdAt: 1
|
|
89
|
+
};
|
|
90
|
+
const code = {
|
|
91
|
+
tokenHash: "code-1",
|
|
92
|
+
grantId: grant.id,
|
|
93
|
+
clientId: client.id,
|
|
94
|
+
subject: firstSubject,
|
|
95
|
+
redirectUri: transaction.redirectUri,
|
|
96
|
+
codeChallenge: transaction.codeChallenge,
|
|
97
|
+
resource: transaction.resource,
|
|
98
|
+
scopes: grant.scopes,
|
|
99
|
+
expiresAt: 10
|
|
100
|
+
};
|
|
101
|
+
await store.putAuthorizationCode(code);
|
|
102
|
+
assert(isDeepStrictEqual(await store.takeAuthorizationCode(code.tokenHash), code) &&
|
|
103
|
+
(await store.takeAuthorizationCode(code.tokenHash)) === undefined, "authorization codes must be consumed atomically");
|
|
104
|
+
await store.putGrant(grant);
|
|
105
|
+
assert(isDeepStrictEqual(await store.getGrant(grant.id), grant), "grants must round-trip");
|
|
106
|
+
const accessToken = {
|
|
107
|
+
tokenHash: "access-1",
|
|
108
|
+
tokenId: "token-1",
|
|
109
|
+
grantId: grant.id,
|
|
110
|
+
subject: firstSubject,
|
|
111
|
+
clientId: client.id,
|
|
112
|
+
resource: transaction.resource,
|
|
113
|
+
expiresAt: 10
|
|
114
|
+
};
|
|
115
|
+
await store.putAccessToken(accessToken);
|
|
116
|
+
assert(isDeepStrictEqual(await store.getAccessToken(accessToken.tokenHash), accessToken), "access tokens must round-trip");
|
|
117
|
+
await store.revokeToken(accessToken.tokenHash, 5);
|
|
118
|
+
assert((await store.getAccessToken(accessToken.tokenHash))?.revokedAt === 5, "access-token revocation must persist");
|
|
119
|
+
const refreshToken = {
|
|
120
|
+
tokenHash: "refresh-1",
|
|
121
|
+
familyId: "family-1",
|
|
122
|
+
grantId: grant.id,
|
|
123
|
+
clientId: client.id,
|
|
124
|
+
subject: firstSubject,
|
|
125
|
+
resource: transaction.resource,
|
|
126
|
+
scopes: grant.scopes,
|
|
127
|
+
createdAt: 1,
|
|
128
|
+
expiresAt: 10,
|
|
129
|
+
status: "active"
|
|
130
|
+
};
|
|
131
|
+
await store.putRefreshToken(refreshToken);
|
|
132
|
+
const rotation = await store.rotateRefreshToken(refreshToken.tokenHash, "refresh-2", 2, 20);
|
|
133
|
+
assert(rotation.status === "rotated" && isDeepStrictEqual(rotation.previous, refreshToken), "refresh tokens must rotate atomically");
|
|
134
|
+
assert((await store.rotateRefreshToken(refreshToken.tokenHash, "refresh-3", 3, 20)).status ===
|
|
135
|
+
"replay", "refresh-token replay must be detected");
|
|
136
|
+
await store.revokeGrant(grant.id, 6);
|
|
137
|
+
assert((await store.getGrant(grant.id))?.revokedAt === 6, "grant revocation must persist");
|
|
138
|
+
await storage.healthCheck?.();
|
|
139
|
+
await storage.cleanup?.(Date.now());
|
|
140
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { createCommandTestHarness, type CommandTestHarness, type ConfirmationReq
|
|
|
2
2
|
export { fakeFetch, fakeService, type FetchRoute, type ServiceCall } from "./fakes.js";
|
|
3
3
|
export { createMemoryFs, type FsChange, type MemoryFs } from "./memory-fs.js";
|
|
4
4
|
export type { ParityResult, SurfaceOutcome } from "./parity.js";
|
|
5
|
+
export { verifyHostedOAuthStorage, type HostedOAuthStorageConformanceOptions } from "./hosted-oauth-storage.js";
|
package/dist/testing/index.js
CHANGED
|
@@ -63,6 +63,7 @@ export type RefreshTokenRotationResult = {
|
|
|
63
63
|
previous: RefreshTokenRecord;
|
|
64
64
|
} | {
|
|
65
65
|
status: "replay";
|
|
66
|
+
grant?: AuthorizationGrantRecord;
|
|
66
67
|
} | {
|
|
67
68
|
status: "invalid";
|
|
68
69
|
};
|
|
@@ -79,7 +80,7 @@ export interface AuthorizationServerStore {
|
|
|
79
80
|
getAccessToken(tokenHash: string): Promise<AccessTokenRecord | undefined>;
|
|
80
81
|
putRefreshToken(token: RefreshTokenRecord): Promise<void>;
|
|
81
82
|
rotateRefreshToken(tokenHash: string, replacementTokenHash: string, now: number, expiresAt: number): Promise<RefreshTokenRotationResult>;
|
|
82
|
-
revokeToken(tokenHash: string, now: number): Promise<void>;
|
|
83
|
+
revokeToken(tokenHash: string, now: number): Promise<void | AuthorizationGrantRecord>;
|
|
83
84
|
revokeGrant(grantId: string, now: number): Promise<void>;
|
|
84
85
|
}
|
|
85
86
|
export interface AuthorizationInteractionStartContext {
|
|
@@ -111,6 +112,7 @@ export interface OAuthAuthorizationServerOptions {
|
|
|
111
112
|
maxRequestBodyBytes?: number;
|
|
112
113
|
now?: () => number;
|
|
113
114
|
randomToken?: () => string;
|
|
115
|
+
onGrantRevoked?(grant: AuthorizationGrantRecord): Promise<void> | void;
|
|
114
116
|
}
|
|
115
117
|
export interface CompleteAuthorizationInput {
|
|
116
118
|
transactionId: string;
|
|
@@ -206,7 +206,11 @@ export function createInMemoryAuthorizationServerStore() {
|
|
|
206
206
|
}
|
|
207
207
|
if (token.status === "rotated") {
|
|
208
208
|
revokeFamily(token.familyId, now);
|
|
209
|
-
|
|
209
|
+
const grant = grants.get(token.grantId);
|
|
210
|
+
return {
|
|
211
|
+
status: "replay",
|
|
212
|
+
...(grant === undefined ? {} : { grant: structuredClone(grant) })
|
|
213
|
+
};
|
|
210
214
|
}
|
|
211
215
|
refreshTokens.set(tokenHash, { ...token, status: "rotated" });
|
|
212
216
|
refreshTokens.set(replacementTokenHash, {
|
|
@@ -220,13 +224,19 @@ export function createInMemoryAuthorizationServerStore() {
|
|
|
220
224
|
},
|
|
221
225
|
async revokeToken(tokenHash, now) {
|
|
222
226
|
const refreshToken = refreshTokens.get(tokenHash);
|
|
227
|
+
const accessToken = accessTokens.get(tokenHash);
|
|
228
|
+
const grantId = refreshToken?.grantId ?? accessToken?.grantId;
|
|
229
|
+
const grant = grantId === undefined ? undefined : grants.get(grantId);
|
|
230
|
+
const alreadyRevoked = grant?.revokedAt !== undefined ||
|
|
231
|
+
refreshToken?.status === "revoked" ||
|
|
232
|
+
accessToken?.revokedAt !== undefined;
|
|
223
233
|
if (refreshToken !== undefined) {
|
|
224
234
|
revokeFamily(refreshToken.familyId, now);
|
|
225
235
|
}
|
|
226
|
-
const accessToken = accessTokens.get(tokenHash);
|
|
227
236
|
if (accessToken !== undefined) {
|
|
228
237
|
accessTokens.set(tokenHash, { ...accessToken, revokedAt: now });
|
|
229
238
|
}
|
|
239
|
+
return grant === undefined || alreadyRevoked ? undefined : structuredClone(grant);
|
|
230
240
|
},
|
|
231
241
|
async revokeGrant(grantId, now) {
|
|
232
242
|
const grant = grants.get(grantId);
|
|
@@ -258,6 +268,10 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
258
268
|
}
|
|
259
269
|
const now = options.now ?? Date.now;
|
|
260
270
|
const randomToken = options.randomToken ?? opaqueToken;
|
|
271
|
+
async function notifyGrantRevoked(grant) {
|
|
272
|
+
if (grant !== undefined)
|
|
273
|
+
await options.onGrantRevoked?.(grant);
|
|
274
|
+
}
|
|
261
275
|
const accessTokenTtlMs = (options.accessTokenTtlSeconds ?? 300) * 1000;
|
|
262
276
|
const authorizationCodeTtlMs = (options.authorizationCodeTtlSeconds ?? 60) * 1000;
|
|
263
277
|
const authorizationTransactionTtlMs = (options.authorizationTransactionTtlSeconds ?? 600) * 1000;
|
|
@@ -534,11 +548,15 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
534
548
|
const tokenHash = hashToken(refreshToken);
|
|
535
549
|
const existing = await options.store.rotateRefreshToken(tokenHash, replacementTokenHash, currentTime, currentTime + refreshTokenTtlMs);
|
|
536
550
|
if (existing.status !== "rotated") {
|
|
551
|
+
if (existing.status === "replay")
|
|
552
|
+
await notifyGrantRevoked(existing.grant);
|
|
537
553
|
throw new OAuthProtocolError("invalid_grant", "Refresh token is invalid or replayed.");
|
|
538
554
|
}
|
|
539
555
|
if (existing.previous.clientId !== body.get("client_id") ||
|
|
540
556
|
existing.previous.resource !== requestedResource) {
|
|
557
|
+
const revokedGrant = await options.store.getGrant(existing.previous.grantId);
|
|
541
558
|
await options.store.revokeGrant(existing.previous.grantId, currentTime);
|
|
559
|
+
await notifyGrantRevoked(revokedGrant);
|
|
542
560
|
throw new OAuthProtocolError("invalid_grant", "Refresh token binding is invalid.");
|
|
543
561
|
}
|
|
544
562
|
const grant = await options.store.getGrant(existing.previous.grantId);
|
|
@@ -563,8 +581,9 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
563
581
|
requireFormContentType(request);
|
|
564
582
|
const body = new URLSearchParams(await readRequestBody(request));
|
|
565
583
|
const token = body.get("token");
|
|
566
|
-
if (token !== null)
|
|
567
|
-
await options.store.revokeToken(hashToken(token), now());
|
|
584
|
+
if (token !== null) {
|
|
585
|
+
await notifyGrantRevoked(await options.store.revokeToken(hashToken(token), now()));
|
|
586
|
+
}
|
|
568
587
|
return new Response(null, { status: 200, headers: { "cache-control": "no-store" } });
|
|
569
588
|
}
|
|
570
589
|
async function handle(request) {
|
|
@@ -655,7 +674,9 @@ export function createOAuthAuthorizationServer(options) {
|
|
|
655
674
|
denyAuthorization,
|
|
656
675
|
verifyAccessToken,
|
|
657
676
|
async revokeGrant(grantId) {
|
|
677
|
+
const grant = await options.store.getGrant(grantId);
|
|
658
678
|
await options.store.revokeGrant(grantId, now());
|
|
679
|
+
await notifyGrantRevoked(grant?.revokedAt === undefined ? grant : undefined);
|
|
659
680
|
}
|
|
660
681
|
};
|
|
661
682
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.149",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"yaml"
|
|
159
159
|
],
|
|
160
160
|
"optionalDependencies": {
|
|
161
|
-
"toolcraft-schema": "0.0.
|
|
161
|
+
"toolcraft-schema": "0.0.149",
|
|
162
162
|
"toolcraft-design": "*",
|
|
163
163
|
"@poe-code/frontmatter": "*",
|
|
164
164
|
"@poe-code/agent-mcp-config": "*",
|