@apifuse/provider-sdk 2.2.0-beta.59 → 2.2.0-beta.61
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/AUTHORING.md +33 -16
- package/CHANGELOG.md +8 -0
- package/bin/apifuse-dev.ts +7 -8
- package/bin/apifuse-pack-types.ts +22 -0
- package/bin/apifuse-perf.ts +45 -10
- package/bin/apifuse-record.ts +6 -8
- package/bin/apifuse-submit-check.ts +2 -2
- package/dist/config/loader.js +13 -12
- package/dist/define.d.ts +3 -2
- package/dist/define.js +1 -1
- package/dist/dev.js +3 -1
- package/dist/engine.d.ts +7 -6
- package/dist/engine.js +6 -6
- package/dist/error-resolution.js +21 -4
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +9 -0
- package/dist/handle-meta.d.ts +39 -0
- package/dist/handle-meta.js +29 -0
- package/dist/handle.d.ts +187 -0
- package/dist/handle.js +254 -0
- package/dist/i18n/catalog.js +21 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -3
- package/dist/lint.d.ts +3 -0
- package/dist/lint.js +388 -62
- package/dist/provider.d.ts +4 -4
- package/dist/provider.js +3 -3
- package/dist/runtime/browser.js +2 -1
- package/dist/runtime/cache.js +3 -2
- package/dist/runtime/diagnostic-env-values.d.ts +5 -0
- package/dist/runtime/diagnostic-env-values.js +57 -0
- package/dist/runtime/diagnostic-env.d.ts +11 -0
- package/dist/runtime/diagnostic-env.js +35 -0
- package/dist/runtime/diagnostic-redactor.d.ts +49 -0
- package/dist/runtime/diagnostic-redactor.js +569 -0
- package/dist/runtime/egress-lease.d.ts +2 -0
- package/dist/runtime/egress-lease.js +4 -1
- package/dist/runtime/env.js +2 -1
- package/dist/runtime/handle-wordlist.d.ts +43 -0
- package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
- package/dist/runtime/handle.d.ts +71 -0
- package/dist/runtime/handle.js +895 -0
- package/dist/runtime/instrumentation.js +3 -0
- package/dist/runtime/native-network.d.ts +10 -1
- package/dist/runtime/native-network.js +230 -29
- package/dist/runtime/native-telemetry.d.ts +142 -0
- package/dist/runtime/native-telemetry.js +231 -0
- package/dist/runtime/ocr.js +2 -1
- package/dist/runtime/otlp.js +5 -4
- package/dist/runtime/proxy-nodemaven.js +3 -2
- package/dist/runtime/request-options.d.ts +7 -0
- package/dist/runtime/request-options.js +62 -22
- package/dist/runtime/resolver-vendors/browser.js +0 -1
- package/dist/runtime/resolver.js +3 -2
- package/dist/runtime/state.d.ts +4 -1
- package/dist/runtime/state.js +32 -17
- package/dist/runtime/stt.js +3 -2
- package/dist/runtime/trace.d.ts +5 -0
- package/dist/runtime/trace.js +115 -20
- package/dist/schema.d.ts +11 -0
- package/dist/schema.js +56 -2
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/self-test-redaction.js +8 -33
- package/dist/server/sensitive-values.d.ts +26 -0
- package/dist/server/sensitive-values.js +128 -0
- package/dist/server/serve-implementation.d.ts +4 -11
- package/dist/server/serve-implementation.js +305 -96
- package/dist/server/trace-output.d.ts +2 -1
- package/dist/server/trace-output.js +46 -14
- package/dist/testing/run.js +10 -7
- package/dist/trace-sanitization.d.ts +4 -3
- package/dist/trace-sanitization.js +45 -22
- package/dist/types.d.ts +4 -107
- package/package.json +2 -1
- package/src/config/loader.ts +13 -12
- package/src/define.ts +4 -3
- package/src/dev.ts +3 -1
- package/src/engine.ts +8 -8
- package/src/error-resolution.ts +21 -4
- package/src/errors.ts +13 -0
- package/src/handle-meta.ts +58 -0
- package/src/handle.ts +529 -0
- package/src/i18n/catalog.ts +23 -1
- package/src/index.ts +28 -13
- package/src/lint.ts +456 -71
- package/src/provider.ts +10 -20
- package/src/runtime/browser.ts +2 -1
- package/src/runtime/cache.ts +3 -2
- package/src/runtime/diagnostic-env-values.ts +56 -0
- package/src/runtime/diagnostic-env.ts +55 -0
- package/src/runtime/diagnostic-redactor.ts +662 -0
- package/src/runtime/egress-lease.ts +6 -1
- package/src/runtime/env.ts +2 -1
- package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
- package/src/runtime/handle.ts +1162 -0
- package/src/runtime/instrumentation.ts +3 -0
- package/src/runtime/native-network.ts +281 -33
- package/src/runtime/native-telemetry.ts +366 -0
- package/src/runtime/ocr.ts +2 -1
- package/src/runtime/otlp.ts +5 -4
- package/src/runtime/proxy-nodemaven.ts +3 -2
- package/src/runtime/request-options.ts +76 -28
- package/src/runtime/resolver-vendors/browser.ts +0 -1
- package/src/runtime/resolver.ts +3 -2
- package/src/runtime/state.ts +44 -16
- package/src/runtime/stt.ts +3 -2
- package/src/runtime/trace.ts +168 -26
- package/src/schema.ts +63 -2
- package/src/server/index.ts +17 -0
- package/src/server/self-test-redaction.ts +10 -40
- package/src/server/sensitive-values.ts +197 -0
- package/src/server/serve-implementation.ts +388 -128
- package/src/server/trace-output.ts +77 -17
- package/src/testing/run.ts +11 -7
- package/src/trace-sanitization.ts +70 -23
- package/src/types.ts +4 -137
- package/dist/choice-token.d.ts +0 -24
- package/dist/choice-token.js +0 -74
- package/dist/runtime/choice-wordlist.d.ts +0 -9
- package/dist/runtime/choice.d.ts +0 -27
- package/dist/runtime/choice.js +0 -801
- package/src/choice-token.ts +0 -165
- package/src/runtime/choice.ts +0 -1197
package/dist/runtime/choice.js
DELETED
|
@@ -1,801 +0,0 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual, } from "node:crypto";
|
|
2
|
-
import { assertFreshProviderChoiceIssuedAt, ProviderChoiceTokenError, } from "../choice-token.js";
|
|
3
|
-
import { isProviderError, ProviderError } from "../errors.js";
|
|
4
|
-
import { CHOICE_WORDLIST_SIZE, choiceWordAt, HIGH_CHOICE_WORD_COUNT, isChoiceWord, STANDARD_CHOICE_WORD_COUNT, } from "./choice-wordlist.js";
|
|
5
|
-
export const PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV = "APIFUSE__PROVIDER_RUNTIME__CHOICE_TOKEN_MASTER_SECRET";
|
|
6
|
-
const PRIMARY_CHOICE_TOKEN_KID = "v1";
|
|
7
|
-
const MANAGED_CHOICE_TOKEN_VERSION = 1;
|
|
8
|
-
const SERVER_STORED_CHOICE_RECORD_VERSION = 1;
|
|
9
|
-
const SERVER_STORED_CHOICE_ISSUE_ATTEMPTS = 5;
|
|
10
|
-
const WORD_CHOICE_NOT_FOUND_MESSAGE = "Provider choice token was not found.";
|
|
11
|
-
export function createProviderChoiceContext(options) {
|
|
12
|
-
const kid = options.kid ?? PRIMARY_CHOICE_TOKEN_KID;
|
|
13
|
-
const resolveMasterSecret = () => resolveChoiceMasterSecret(options);
|
|
14
|
-
function issue(issueOptions) {
|
|
15
|
-
const issuedAtMs = issueOptions.nowMs ?? Date.now();
|
|
16
|
-
const resolvedStorage = resolveIssueStorage(issueOptions.storage, issueOptions.payload);
|
|
17
|
-
if (resolvedStorage.mode === "server") {
|
|
18
|
-
const keys = hasRequestedChoiceBinding(issueOptions.bind)
|
|
19
|
-
? deriveManagedChoiceKeys({
|
|
20
|
-
masterSecret: resolveMasterSecret(),
|
|
21
|
-
providerId: options.providerId,
|
|
22
|
-
purpose: issueOptions.purpose,
|
|
23
|
-
kid,
|
|
24
|
-
})
|
|
25
|
-
: undefined;
|
|
26
|
-
const binding = hasRequestedChoiceBinding(issueOptions.bind)
|
|
27
|
-
? createChoiceBinding({
|
|
28
|
-
keys: keys,
|
|
29
|
-
options: issueOptions.bind,
|
|
30
|
-
request: options.request,
|
|
31
|
-
credential: options.credential,
|
|
32
|
-
required: true,
|
|
33
|
-
})
|
|
34
|
-
: undefined;
|
|
35
|
-
const baseEnvelope = {
|
|
36
|
-
v: MANAGED_CHOICE_TOKEN_VERSION,
|
|
37
|
-
provider_id: options.providerId,
|
|
38
|
-
purpose: issueOptions.purpose,
|
|
39
|
-
issued_at_ms: issuedAtMs,
|
|
40
|
-
ttl_ms: issueOptions.ttlMs,
|
|
41
|
-
binding,
|
|
42
|
-
};
|
|
43
|
-
return issueWordServerStoredChoice({
|
|
44
|
-
baseEnvelope,
|
|
45
|
-
issueOptions,
|
|
46
|
-
storage: resolvedStorage.storage,
|
|
47
|
-
contextState: options.state,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
const keys = deriveManagedChoiceKeys({
|
|
51
|
-
masterSecret: resolveMasterSecret(),
|
|
52
|
-
providerId: options.providerId,
|
|
53
|
-
purpose: issueOptions.purpose,
|
|
54
|
-
kid,
|
|
55
|
-
});
|
|
56
|
-
const baseEnvelope = {
|
|
57
|
-
v: MANAGED_CHOICE_TOKEN_VERSION,
|
|
58
|
-
provider_id: options.providerId,
|
|
59
|
-
purpose: issueOptions.purpose,
|
|
60
|
-
issued_at_ms: issuedAtMs,
|
|
61
|
-
ttl_ms: issueOptions.ttlMs,
|
|
62
|
-
binding: createChoiceBinding({
|
|
63
|
-
keys,
|
|
64
|
-
options: issueOptions.bind,
|
|
65
|
-
request: options.request,
|
|
66
|
-
credential: options.credential,
|
|
67
|
-
required: true,
|
|
68
|
-
}),
|
|
69
|
-
};
|
|
70
|
-
const envelope = {
|
|
71
|
-
...baseEnvelope,
|
|
72
|
-
payload: issueOptions.payload,
|
|
73
|
-
};
|
|
74
|
-
return encryptManagedChoiceToken({
|
|
75
|
-
prefix: issueOptions.prefix,
|
|
76
|
-
kid,
|
|
77
|
-
envelope,
|
|
78
|
-
keys,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
function parse(parseOptions) {
|
|
82
|
-
const consumeMode = parseOptions.consume ?? "never";
|
|
83
|
-
const wordStateKey = parseWordChoiceStateKey({
|
|
84
|
-
token: parseOptions.token,
|
|
85
|
-
prefix: parseOptions.prefix,
|
|
86
|
-
});
|
|
87
|
-
if (wordStateKey) {
|
|
88
|
-
const parsed = parseWordServerStoredChoice({
|
|
89
|
-
stateKey: wordStateKey,
|
|
90
|
-
parseOptions,
|
|
91
|
-
contextState: options.state,
|
|
92
|
-
providerId: options.providerId,
|
|
93
|
-
request: options.request,
|
|
94
|
-
credential: options.credential,
|
|
95
|
-
resolveBindingKeys: () => deriveManagedChoiceKeys({
|
|
96
|
-
masterSecret: resolveMasterSecret(),
|
|
97
|
-
providerId: options.providerId,
|
|
98
|
-
purpose: parseOptions.purpose,
|
|
99
|
-
kid,
|
|
100
|
-
}),
|
|
101
|
-
onConsume: (result) => emitChoiceTelemetry(options.onTelemetry, {
|
|
102
|
-
providerId: options.providerId,
|
|
103
|
-
purpose: parseOptions.purpose,
|
|
104
|
-
operation: "consume",
|
|
105
|
-
format: "word",
|
|
106
|
-
outcome: "success",
|
|
107
|
-
consumeMode,
|
|
108
|
-
consumed: result.status === "consumed",
|
|
109
|
-
replay: result.status === "already-consumed",
|
|
110
|
-
}),
|
|
111
|
-
});
|
|
112
|
-
return observeChoiceParse(parsed, {
|
|
113
|
-
onTelemetry: options.onTelemetry,
|
|
114
|
-
providerId: options.providerId,
|
|
115
|
-
purpose: parseOptions.purpose,
|
|
116
|
-
format: "word",
|
|
117
|
-
consumeMode,
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
// Inline choices continue to use the encrypted envelope. A structurally
|
|
121
|
-
// valid word token returns above, so lookup, expiry, consumption, and
|
|
122
|
-
// binding failures can never enter this branch.
|
|
123
|
-
try {
|
|
124
|
-
const [actualPrefix, tokenKid, encodedIv, encryptedPayload, authTag, signature] = parseManagedChoiceTokenParts(parseOptions.token);
|
|
125
|
-
if (actualPrefix !== parseOptions.prefix ||
|
|
126
|
-
tokenKid !== kid ||
|
|
127
|
-
!encodedIv ||
|
|
128
|
-
!encryptedPayload ||
|
|
129
|
-
!authTag ||
|
|
130
|
-
!signature) {
|
|
131
|
-
throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
|
|
132
|
-
}
|
|
133
|
-
const keys = deriveManagedChoiceKeys({
|
|
134
|
-
masterSecret: resolveMasterSecret(),
|
|
135
|
-
providerId: options.providerId,
|
|
136
|
-
purpose: parseOptions.purpose,
|
|
137
|
-
kid: tokenKid,
|
|
138
|
-
});
|
|
139
|
-
const signedBody = [parseOptions.prefix, tokenKid, encodedIv, encryptedPayload, authTag].join(".");
|
|
140
|
-
assertManagedChoiceSignature({
|
|
141
|
-
signedBody,
|
|
142
|
-
signature,
|
|
143
|
-
signingKey: keys.signing,
|
|
144
|
-
});
|
|
145
|
-
const envelope = decryptManagedChoiceToken({
|
|
146
|
-
encodedIv,
|
|
147
|
-
encryptedPayload,
|
|
148
|
-
authTag,
|
|
149
|
-
encryptionKey: keys.encryption,
|
|
150
|
-
});
|
|
151
|
-
assertManagedChoiceEnvelope(envelope, {
|
|
152
|
-
providerId: options.providerId,
|
|
153
|
-
purpose: parseOptions.purpose,
|
|
154
|
-
ttlMs: parseOptions.ttlMs,
|
|
155
|
-
nowMs: parseOptions.nowMs,
|
|
156
|
-
futureToleranceMs: parseOptions.futureToleranceMs,
|
|
157
|
-
});
|
|
158
|
-
assertChoiceBindingMatches({
|
|
159
|
-
actual: envelope.binding,
|
|
160
|
-
expected: createChoiceBinding({
|
|
161
|
-
keys,
|
|
162
|
-
options: parseOptions.bind,
|
|
163
|
-
request: options.request,
|
|
164
|
-
credential: options.credential,
|
|
165
|
-
required: true,
|
|
166
|
-
}),
|
|
167
|
-
});
|
|
168
|
-
if (isServerChoiceHandlePayload(envelope.payload)) {
|
|
169
|
-
throw wordChoiceNotFoundError();
|
|
170
|
-
}
|
|
171
|
-
const payload = envelope.payload;
|
|
172
|
-
const parsed = consumeMode === "explicit"
|
|
173
|
-
? Promise.resolve(payload).then((resolvedPayload) => createInlineExplicitParseResult({
|
|
174
|
-
payload: resolvedPayload,
|
|
175
|
-
replayKey: digestChoiceReplayKey(parseOptions.token),
|
|
176
|
-
onConsume: () => emitChoiceTelemetry(options.onTelemetry, {
|
|
177
|
-
providerId: options.providerId,
|
|
178
|
-
purpose: parseOptions.purpose,
|
|
179
|
-
operation: "consume",
|
|
180
|
-
format: "legacy",
|
|
181
|
-
outcome: "unsupported",
|
|
182
|
-
consumeMode,
|
|
183
|
-
consumed: false,
|
|
184
|
-
replay: false,
|
|
185
|
-
}),
|
|
186
|
-
}))
|
|
187
|
-
: payload;
|
|
188
|
-
return observeChoiceParse(parsed, {
|
|
189
|
-
onTelemetry: options.onTelemetry,
|
|
190
|
-
providerId: options.providerId,
|
|
191
|
-
purpose: parseOptions.purpose,
|
|
192
|
-
format: "legacy",
|
|
193
|
-
consumeMode,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
catch (error) {
|
|
197
|
-
emitChoiceParseFailure(options.onTelemetry, error, {
|
|
198
|
-
providerId: options.providerId,
|
|
199
|
-
purpose: parseOptions.purpose,
|
|
200
|
-
format: "legacy",
|
|
201
|
-
consumeMode,
|
|
202
|
-
});
|
|
203
|
-
throw error;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return { issue, parse };
|
|
207
|
-
}
|
|
208
|
-
export function createTestProviderChoiceContext(options) {
|
|
209
|
-
return createProviderChoiceContext({
|
|
210
|
-
...options,
|
|
211
|
-
masterSecret: options.masterSecret ?? "apifuse-test-provider-runtime-choice-token-master-secret",
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
function observeChoiceParse(result, base) {
|
|
215
|
-
if (result instanceof Promise) {
|
|
216
|
-
return result.then((value) => {
|
|
217
|
-
emitChoiceParseSuccess(base, value);
|
|
218
|
-
return value;
|
|
219
|
-
}, (error) => {
|
|
220
|
-
emitChoiceParseFailure(base.onTelemetry, error, base);
|
|
221
|
-
throw error;
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
emitChoiceParseSuccess(base, result);
|
|
225
|
-
return result;
|
|
226
|
-
}
|
|
227
|
-
function emitChoiceParseSuccess(base, result) {
|
|
228
|
-
const replay = isConsumedChoiceReplay(result);
|
|
229
|
-
emitChoiceTelemetry(base.onTelemetry, {
|
|
230
|
-
providerId: base.providerId,
|
|
231
|
-
purpose: base.purpose,
|
|
232
|
-
operation: "parse",
|
|
233
|
-
format: base.format,
|
|
234
|
-
outcome: "success",
|
|
235
|
-
consumeMode: base.consumeMode,
|
|
236
|
-
consumed: replay || (base.format === "word" && base.consumeMode === "on-parse"),
|
|
237
|
-
replay,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
function isConsumedChoiceReplay(value) {
|
|
241
|
-
return (value !== null &&
|
|
242
|
-
typeof value === "object" &&
|
|
243
|
-
"status" in value &&
|
|
244
|
-
value.status === "consumed" &&
|
|
245
|
-
"replayKey" in value &&
|
|
246
|
-
typeof value.replayKey === "string");
|
|
247
|
-
}
|
|
248
|
-
function emitChoiceParseFailure(onTelemetry, error, base) {
|
|
249
|
-
const outcome = error instanceof ProviderChoiceTokenError
|
|
250
|
-
? base.format === "word" && error.message === WORD_CHOICE_NOT_FOUND_MESSAGE
|
|
251
|
-
? "not-found"
|
|
252
|
-
: "invalid"
|
|
253
|
-
: "error";
|
|
254
|
-
emitChoiceTelemetry(onTelemetry, {
|
|
255
|
-
providerId: base.providerId,
|
|
256
|
-
purpose: base.purpose,
|
|
257
|
-
operation: "parse",
|
|
258
|
-
format: base.format,
|
|
259
|
-
outcome,
|
|
260
|
-
consumeMode: base.consumeMode,
|
|
261
|
-
consumed: false,
|
|
262
|
-
replay: false,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
function emitChoiceTelemetry(onTelemetry, event) {
|
|
266
|
-
try {
|
|
267
|
-
onTelemetry?.(event);
|
|
268
|
-
}
|
|
269
|
-
catch {
|
|
270
|
-
// Observability must never change provider token semantics.
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
function createInlineExplicitParseResult(options) {
|
|
274
|
-
return {
|
|
275
|
-
status: "active",
|
|
276
|
-
payload: options.payload,
|
|
277
|
-
replayKey: options.replayKey,
|
|
278
|
-
consume: async () => {
|
|
279
|
-
options.onConsume();
|
|
280
|
-
return { status: "unsupported" };
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
function resolveChoiceMasterSecret(options) {
|
|
285
|
-
const configured = options.masterSecret ?? options.env?.get(PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV);
|
|
286
|
-
const trimmed = configured?.trim();
|
|
287
|
-
if (trimmed)
|
|
288
|
-
return trimmed;
|
|
289
|
-
throw new ProviderError("Provider runtime choice-token master secret is not configured.", {
|
|
290
|
-
code: "CHOICE_TOKEN_MASTER_SECRET_NOT_CONFIGURED",
|
|
291
|
-
category: "internal_error",
|
|
292
|
-
retryable: false,
|
|
293
|
-
details: {
|
|
294
|
-
secret: PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
|
|
295
|
-
},
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
function deriveManagedChoiceKeys(input) {
|
|
299
|
-
return {
|
|
300
|
-
encryption: deriveManagedChoiceKey(input, "encryption"),
|
|
301
|
-
signing: deriveManagedChoiceKey(input, "signing"),
|
|
302
|
-
binding: deriveManagedChoiceKey(input, "binding"),
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
function deriveManagedChoiceKey(input, usage) {
|
|
306
|
-
return createHmac("sha256", input.masterSecret)
|
|
307
|
-
.update("apifuse-provider-choice-token")
|
|
308
|
-
.update("\0")
|
|
309
|
-
.update(input.providerId)
|
|
310
|
-
.update("\0")
|
|
311
|
-
.update(input.purpose)
|
|
312
|
-
.update("\0")
|
|
313
|
-
.update(input.kid)
|
|
314
|
-
.update("\0")
|
|
315
|
-
.update(usage)
|
|
316
|
-
.digest();
|
|
317
|
-
}
|
|
318
|
-
function encryptManagedChoiceToken(options) {
|
|
319
|
-
const iv = randomBytes(12);
|
|
320
|
-
const cipher = createCipheriv("aes-256-gcm", options.keys.encryption, iv);
|
|
321
|
-
const encryptedPayload = Buffer.concat([
|
|
322
|
-
cipher.update(JSON.stringify(options.envelope), "utf8"),
|
|
323
|
-
cipher.final(),
|
|
324
|
-
]).toString("base64url");
|
|
325
|
-
const authTag = cipher.getAuthTag().toString("base64url");
|
|
326
|
-
const encodedIv = iv.toString("base64url");
|
|
327
|
-
const signedBody = [options.prefix, options.kid, encodedIv, encryptedPayload, authTag].join(".");
|
|
328
|
-
const signature = createHmac("sha256", options.keys.signing)
|
|
329
|
-
.update(signedBody)
|
|
330
|
-
.digest("base64url");
|
|
331
|
-
return `${signedBody}.${signature}`;
|
|
332
|
-
}
|
|
333
|
-
async function issueWordServerStoredChoice(options) {
|
|
334
|
-
const serializedPayload = serializeChoicePayload(options.issueOptions.payload);
|
|
335
|
-
const payloadDigest = digestChoicePayload(serializedPayload);
|
|
336
|
-
const namespace = resolveChoiceStateNamespace({
|
|
337
|
-
storage: options.storage,
|
|
338
|
-
contextState: options.contextState,
|
|
339
|
-
ttlMs: options.issueOptions.ttlMs,
|
|
340
|
-
});
|
|
341
|
-
const wordCount = options.issueOptions.strength === "high" ? HIGH_CHOICE_WORD_COUNT : STANDARD_CHOICE_WORD_COUNT;
|
|
342
|
-
for (let attempt = 0; attempt < SERVER_STORED_CHOICE_ISSUE_ATTEMPTS; attempt += 1) {
|
|
343
|
-
const stateKey = generateChoiceWordSequence(wordCount);
|
|
344
|
-
const token = `${options.issueOptions.prefix}${stateKey}`;
|
|
345
|
-
const record = {
|
|
346
|
-
v: SERVER_STORED_CHOICE_RECORD_VERSION,
|
|
347
|
-
storage: "server",
|
|
348
|
-
status: "active",
|
|
349
|
-
provider_id: options.baseEnvelope.provider_id,
|
|
350
|
-
purpose: options.baseEnvelope.purpose,
|
|
351
|
-
issued_at_ms: options.baseEnvelope.issued_at_ms,
|
|
352
|
-
ttl_ms: options.baseEnvelope.ttl_ms,
|
|
353
|
-
binding: options.baseEnvelope.binding,
|
|
354
|
-
prefix: options.issueOptions.prefix,
|
|
355
|
-
payload: options.issueOptions.payload,
|
|
356
|
-
payload_digest: payloadDigest,
|
|
357
|
-
replay_key: digestChoiceReplayKey(token),
|
|
358
|
-
};
|
|
359
|
-
const valueBytes = Buffer.byteLength(JSON.stringify(record), "utf8");
|
|
360
|
-
if (valueBytes > options.storage.maxValueBytes) {
|
|
361
|
-
throw new ProviderError("Provider choice payload exceeds state storage policy.", {
|
|
362
|
-
code: "CHOICE_STATE_PAYLOAD_TOO_LARGE",
|
|
363
|
-
category: "input_validation",
|
|
364
|
-
retryable: false,
|
|
365
|
-
details: {
|
|
366
|
-
maxValueBytes: options.storage.maxValueBytes,
|
|
367
|
-
valueBytes,
|
|
368
|
-
},
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
const result = await namespace.compareAndSet(optionsStateKey(stateKey), 0, record, {
|
|
372
|
-
ttl: stateTtl(options.storage, options.issueOptions.ttlMs),
|
|
373
|
-
});
|
|
374
|
-
if (result.ok)
|
|
375
|
-
return token;
|
|
376
|
-
}
|
|
377
|
-
throw new ProviderError("Provider choice state storage is not available.", {
|
|
378
|
-
code: "CHOICE_STATE_UNAVAILABLE",
|
|
379
|
-
category: "internal_error",
|
|
380
|
-
retryable: false,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
async function parseWordServerStoredChoice(options) {
|
|
384
|
-
const storage = resolveParseStorage(options.parseOptions.storage);
|
|
385
|
-
const namespace = resolveChoiceStateNamespace({
|
|
386
|
-
storage,
|
|
387
|
-
contextState: options.contextState,
|
|
388
|
-
ttlMs: options.parseOptions.ttlMs,
|
|
389
|
-
});
|
|
390
|
-
let stored;
|
|
391
|
-
try {
|
|
392
|
-
stored = await namespace.get(optionsStateKey(options.stateKey));
|
|
393
|
-
}
|
|
394
|
-
catch (error) {
|
|
395
|
-
if (isProviderError(error))
|
|
396
|
-
throw error;
|
|
397
|
-
throw wordChoiceNotFoundError();
|
|
398
|
-
}
|
|
399
|
-
if (!stored || !isServerStoredChoiceRecord(stored.value)) {
|
|
400
|
-
throw wordChoiceNotFoundError();
|
|
401
|
-
}
|
|
402
|
-
const record = stored.value;
|
|
403
|
-
const expectedReplayKey = digestChoiceReplayKey(`${options.parseOptions.prefix}${options.stateKey}`);
|
|
404
|
-
try {
|
|
405
|
-
if (record.provider_id !== options.providerId ||
|
|
406
|
-
record.purpose !== options.parseOptions.purpose ||
|
|
407
|
-
record.prefix !== options.parseOptions.prefix) {
|
|
408
|
-
throw wordChoiceNotFoundError();
|
|
409
|
-
}
|
|
410
|
-
assertPayloadDigestMatches({
|
|
411
|
-
actual: digestChoicePayload(serializeChoicePayload(record.payload)),
|
|
412
|
-
expected: record.payload_digest,
|
|
413
|
-
});
|
|
414
|
-
assertPayloadDigestMatches({ actual: expectedReplayKey, expected: record.replay_key });
|
|
415
|
-
assertWordChoiceBindingMatches({
|
|
416
|
-
actual: record.binding,
|
|
417
|
-
requested: options.parseOptions.bind,
|
|
418
|
-
request: options.request,
|
|
419
|
-
credential: options.credential,
|
|
420
|
-
resolveKeys: options.resolveBindingKeys,
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
catch (error) {
|
|
424
|
-
if (error instanceof ProviderChoiceTokenError ||
|
|
425
|
-
(isProviderError(error) && error.code === "CHOICE_CONTEXT_REQUIRED")) {
|
|
426
|
-
throw wordChoiceNotFoundError();
|
|
427
|
-
}
|
|
428
|
-
throw error;
|
|
429
|
-
}
|
|
430
|
-
// Freshness is classified last, reachable only after every identity,
|
|
431
|
-
// integrity, and binding check above has passed (ADR 0006, amended
|
|
432
|
-
// 2026-08-20): a caller that proved the record's binding may observe the
|
|
433
|
-
// canonical stale error, while an unbound record keeps the collapsed
|
|
434
|
-
// not-found error so expiry never becomes an existence signal for
|
|
435
|
-
// guessable tokens.
|
|
436
|
-
try {
|
|
437
|
-
assertFreshProviderChoiceIssuedAt(record.issued_at_ms, {
|
|
438
|
-
ttlMs: options.parseOptions.ttlMs != null
|
|
439
|
-
? Math.min(options.parseOptions.ttlMs, record.ttl_ms)
|
|
440
|
-
: record.ttl_ms,
|
|
441
|
-
nowMs: options.parseOptions.nowMs,
|
|
442
|
-
futureToleranceMs: options.parseOptions.futureToleranceMs,
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
catch (error) {
|
|
446
|
-
const recordIsBound = Boolean(record.binding?.connection_hash || record.binding?.credential_hash);
|
|
447
|
-
if (recordIsBound && error instanceof ProviderChoiceTokenError && error.reason === "stale") {
|
|
448
|
-
throw error;
|
|
449
|
-
}
|
|
450
|
-
if (error instanceof ProviderChoiceTokenError)
|
|
451
|
-
throw wordChoiceNotFoundError();
|
|
452
|
-
throw error;
|
|
453
|
-
}
|
|
454
|
-
const consumeMode = options.parseOptions.consume ?? "never";
|
|
455
|
-
if (record.status === "consumed") {
|
|
456
|
-
if (consumeMode === "explicit") {
|
|
457
|
-
return { status: "consumed", replayKey: record.replay_key };
|
|
458
|
-
}
|
|
459
|
-
throw wordChoiceNotFoundError();
|
|
460
|
-
}
|
|
461
|
-
if (consumeMode === "never")
|
|
462
|
-
return record.payload;
|
|
463
|
-
if (consumeMode === "explicit") {
|
|
464
|
-
return {
|
|
465
|
-
status: "active",
|
|
466
|
-
payload: record.payload,
|
|
467
|
-
replayKey: record.replay_key,
|
|
468
|
-
consume: async () => {
|
|
469
|
-
const result = await consumeWordServerStoredChoice({
|
|
470
|
-
stateKey: options.stateKey,
|
|
471
|
-
stored,
|
|
472
|
-
record,
|
|
473
|
-
storage,
|
|
474
|
-
contextState: options.contextState,
|
|
475
|
-
});
|
|
476
|
-
options.onConsume(result);
|
|
477
|
-
return result;
|
|
478
|
-
},
|
|
479
|
-
};
|
|
480
|
-
}
|
|
481
|
-
const consumed = await consumeWordServerStoredChoice({
|
|
482
|
-
stateKey: options.stateKey,
|
|
483
|
-
stored,
|
|
484
|
-
record,
|
|
485
|
-
storage,
|
|
486
|
-
contextState: options.contextState,
|
|
487
|
-
});
|
|
488
|
-
if (consumed.status !== "consumed")
|
|
489
|
-
throw wordChoiceNotFoundError();
|
|
490
|
-
return record.payload;
|
|
491
|
-
}
|
|
492
|
-
async function consumeWordServerStoredChoice(options) {
|
|
493
|
-
const namespace = resolveChoiceStateNamespace({
|
|
494
|
-
storage: options.storage,
|
|
495
|
-
contextState: options.contextState,
|
|
496
|
-
ttlMs: options.record.ttl_ms,
|
|
497
|
-
});
|
|
498
|
-
try {
|
|
499
|
-
const consumed = await namespace.compareAndSet(optionsStateKey(options.stateKey), options.stored.version, { ...options.record, status: "consumed" }, { ttl: remainingStateTtl(options.stored.expiresAt) });
|
|
500
|
-
if (consumed.ok)
|
|
501
|
-
return { status: "consumed" };
|
|
502
|
-
if (consumed.current &&
|
|
503
|
-
isServerStoredChoiceRecord(consumed.current.value) &&
|
|
504
|
-
consumed.current.value.status === "consumed" &&
|
|
505
|
-
consumed.current.value.replay_key === options.record.replay_key) {
|
|
506
|
-
return { status: "already-consumed" };
|
|
507
|
-
}
|
|
508
|
-
throw wordChoiceNotFoundError();
|
|
509
|
-
}
|
|
510
|
-
catch (error) {
|
|
511
|
-
if (isProviderError(error))
|
|
512
|
-
throw error;
|
|
513
|
-
if (error instanceof ProviderChoiceTokenError)
|
|
514
|
-
throw error;
|
|
515
|
-
throw wordChoiceNotFoundError();
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
function generateChoiceWordSequence(wordCount) {
|
|
519
|
-
return Array.from({ length: wordCount }, () => choiceWordAt(randomInt(CHOICE_WORDLIST_SIZE))).join("-");
|
|
520
|
-
}
|
|
521
|
-
function parseWordChoiceStateKey(options) {
|
|
522
|
-
if (!options.token.startsWith(options.prefix))
|
|
523
|
-
return null;
|
|
524
|
-
const body = options.token.slice(options.prefix.length);
|
|
525
|
-
// The official list contains one hyphenated entry (`yo-yo`), so structural
|
|
526
|
-
// recognition uses dictionary-aware segmentation instead of assuming every
|
|
527
|
-
// hyphen is a word boundary.
|
|
528
|
-
if (!/^[a-z]+(?:-[a-z]+){3,9}$/.test(body))
|
|
529
|
-
return null;
|
|
530
|
-
const segments = body.split("-");
|
|
531
|
-
if (!canSegmentChoiceWords(segments, 0, STANDARD_CHOICE_WORD_COUNT) &&
|
|
532
|
-
!canSegmentChoiceWords(segments, 0, HIGH_CHOICE_WORD_COUNT)) {
|
|
533
|
-
return null;
|
|
534
|
-
}
|
|
535
|
-
return body;
|
|
536
|
-
}
|
|
537
|
-
function canSegmentChoiceWords(segments, segmentIndex, wordsRemaining) {
|
|
538
|
-
if (wordsRemaining === 0)
|
|
539
|
-
return segmentIndex === segments.length;
|
|
540
|
-
const segmentsRemaining = segments.length - segmentIndex;
|
|
541
|
-
if (segmentsRemaining < wordsRemaining)
|
|
542
|
-
return false;
|
|
543
|
-
for (let end = segmentIndex + 1; end <= segments.length - (wordsRemaining - 1); end += 1) {
|
|
544
|
-
const candidate = segments.slice(segmentIndex, end).join("-");
|
|
545
|
-
if (candidate.length > 10)
|
|
546
|
-
break;
|
|
547
|
-
if (isChoiceWord(candidate) && canSegmentChoiceWords(segments, end, wordsRemaining - 1)) {
|
|
548
|
-
return true;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
return false;
|
|
552
|
-
}
|
|
553
|
-
function isServerStoredChoiceRecord(value) {
|
|
554
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
555
|
-
return false;
|
|
556
|
-
return ("v" in value &&
|
|
557
|
-
value.v === SERVER_STORED_CHOICE_RECORD_VERSION &&
|
|
558
|
-
"storage" in value &&
|
|
559
|
-
value.storage === "server" &&
|
|
560
|
-
"status" in value &&
|
|
561
|
-
(value.status === "active" || value.status === "consumed") &&
|
|
562
|
-
"provider_id" in value &&
|
|
563
|
-
typeof value.provider_id === "string" &&
|
|
564
|
-
"purpose" in value &&
|
|
565
|
-
typeof value.purpose === "string" &&
|
|
566
|
-
"issued_at_ms" in value &&
|
|
567
|
-
typeof value.issued_at_ms === "number" &&
|
|
568
|
-
"ttl_ms" in value &&
|
|
569
|
-
typeof value.ttl_ms === "number" &&
|
|
570
|
-
(!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)) &&
|
|
571
|
-
"prefix" in value &&
|
|
572
|
-
typeof value.prefix === "string" &&
|
|
573
|
-
"payload" in value &&
|
|
574
|
-
isChoicePayload(value.payload) &&
|
|
575
|
-
"payload_digest" in value &&
|
|
576
|
-
typeof value.payload_digest === "string" &&
|
|
577
|
-
"replay_key" in value &&
|
|
578
|
-
typeof value.replay_key === "string");
|
|
579
|
-
}
|
|
580
|
-
function wordChoiceNotFoundError() {
|
|
581
|
-
return new ProviderChoiceTokenError("invalid_payload", WORD_CHOICE_NOT_FOUND_MESSAGE);
|
|
582
|
-
}
|
|
583
|
-
function resolveIssueStorage(storage, payload) {
|
|
584
|
-
if (!storage || storage.mode === "inline")
|
|
585
|
-
return { mode: "inline" };
|
|
586
|
-
if (storage.mode === "server")
|
|
587
|
-
return { mode: "server", storage };
|
|
588
|
-
const payloadBytes = Buffer.byteLength(serializeChoicePayload(payload), "utf8");
|
|
589
|
-
if (payloadBytes <= storage.maxInlineBytes)
|
|
590
|
-
return { mode: "inline" };
|
|
591
|
-
return { mode: "server", storage };
|
|
592
|
-
}
|
|
593
|
-
function resolveParseStorage(storage) {
|
|
594
|
-
if (!storage || storage.mode === "inline") {
|
|
595
|
-
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token requires server-side choice storage.");
|
|
596
|
-
}
|
|
597
|
-
return storage;
|
|
598
|
-
}
|
|
599
|
-
function resolveChoiceStateNamespace(options) {
|
|
600
|
-
const state = options.storage.state ?? options.contextState;
|
|
601
|
-
if (!state) {
|
|
602
|
-
throw new ProviderError("Provider choice state storage is not available.", {
|
|
603
|
-
code: "CHOICE_STATE_UNAVAILABLE",
|
|
604
|
-
category: "internal_error",
|
|
605
|
-
retryable: false,
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
return state.namespace(options.storage.namespace, {
|
|
609
|
-
defaultTtl: stateTtl(options.storage, options.ttlMs),
|
|
610
|
-
maxTtl: stateTtl(options.storage, options.ttlMs),
|
|
611
|
-
maxEntries: options.storage.maxEntries,
|
|
612
|
-
maxValueBytes: options.storage.maxValueBytes,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
function stateTtl(storage, ttlMs) {
|
|
616
|
-
return storage.ttl ?? `${ttlMs ?? 1}ms`;
|
|
617
|
-
}
|
|
618
|
-
function remainingStateTtl(expiresAt) {
|
|
619
|
-
const remainingMs = Date.parse(expiresAt) - Date.now();
|
|
620
|
-
return `${Number.isFinite(remainingMs) ? Math.max(1, Math.floor(remainingMs)) : 1}ms`;
|
|
621
|
-
}
|
|
622
|
-
function optionsStateKey(stateId) {
|
|
623
|
-
return stateId;
|
|
624
|
-
}
|
|
625
|
-
function serializeChoicePayload(payload) {
|
|
626
|
-
return JSON.stringify(payload);
|
|
627
|
-
}
|
|
628
|
-
function digestChoicePayload(serializedPayload) {
|
|
629
|
-
return createHash("sha256").update(serializedPayload).digest("base64url");
|
|
630
|
-
}
|
|
631
|
-
function digestChoiceReplayKey(token) {
|
|
632
|
-
return createHash("sha256").update(token).digest("hex");
|
|
633
|
-
}
|
|
634
|
-
function isServerChoiceHandlePayload(value) {
|
|
635
|
-
return (value.storage === "server" &&
|
|
636
|
-
typeof value.state_id === "string" &&
|
|
637
|
-
typeof value.payload_digest === "string" &&
|
|
638
|
-
typeof value.created_at_ms === "number");
|
|
639
|
-
}
|
|
640
|
-
function assertPayloadDigestMatches(options) {
|
|
641
|
-
const actual = Buffer.from(options.actual);
|
|
642
|
-
const expected = Buffer.from(options.expected);
|
|
643
|
-
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
644
|
-
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token state payload digest is invalid.");
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
function parseManagedChoiceTokenParts(token) {
|
|
648
|
-
const parts = token.split(".");
|
|
649
|
-
if (parts.length !== 6) {
|
|
650
|
-
throw new ProviderChoiceTokenError("invalid_shape", "Provider choice token shape is invalid.");
|
|
651
|
-
}
|
|
652
|
-
return [parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]];
|
|
653
|
-
}
|
|
654
|
-
function assertManagedChoiceSignature(options) {
|
|
655
|
-
const expected = createHmac("sha256", options.signingKey)
|
|
656
|
-
.update(options.signedBody)
|
|
657
|
-
.digest("base64url");
|
|
658
|
-
const actualBuffer = Buffer.from(options.signature);
|
|
659
|
-
const expectedBuffer = Buffer.from(expected);
|
|
660
|
-
if (actualBuffer.length !== expectedBuffer.length ||
|
|
661
|
-
!timingSafeEqual(actualBuffer, expectedBuffer)) {
|
|
662
|
-
throw new ProviderChoiceTokenError("invalid_signature", "Provider choice token signature is invalid.");
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
function decryptManagedChoiceToken(options) {
|
|
666
|
-
try {
|
|
667
|
-
const decipher = createDecipheriv("aes-256-gcm", options.encryptionKey, Buffer.from(options.encodedIv, "base64url"));
|
|
668
|
-
decipher.setAuthTag(Buffer.from(options.authTag, "base64url"));
|
|
669
|
-
const decrypted = Buffer.concat([
|
|
670
|
-
decipher.update(Buffer.from(options.encryptedPayload, "base64url")),
|
|
671
|
-
decipher.final(),
|
|
672
|
-
]).toString("utf8");
|
|
673
|
-
const parsed = JSON.parse(decrypted);
|
|
674
|
-
if (!isManagedChoiceEnvelope(parsed)) {
|
|
675
|
-
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token payload is invalid.");
|
|
676
|
-
}
|
|
677
|
-
return parsed;
|
|
678
|
-
}
|
|
679
|
-
catch (error) {
|
|
680
|
-
if (error instanceof ProviderChoiceTokenError) {
|
|
681
|
-
throw error;
|
|
682
|
-
}
|
|
683
|
-
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token payload is invalid.");
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
function isManagedChoiceEnvelope(value) {
|
|
687
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
688
|
-
return false;
|
|
689
|
-
if (!("payload" in value) || !isChoicePayload(value.payload))
|
|
690
|
-
return false;
|
|
691
|
-
return ("v" in value &&
|
|
692
|
-
value.v === MANAGED_CHOICE_TOKEN_VERSION &&
|
|
693
|
-
"provider_id" in value &&
|
|
694
|
-
typeof value.provider_id === "string" &&
|
|
695
|
-
"purpose" in value &&
|
|
696
|
-
typeof value.purpose === "string" &&
|
|
697
|
-
"issued_at_ms" in value &&
|
|
698
|
-
typeof value.issued_at_ms === "number" &&
|
|
699
|
-
"ttl_ms" in value &&
|
|
700
|
-
typeof value.ttl_ms === "number" &&
|
|
701
|
-
(!("binding" in value) || value.binding === undefined || isChoiceBinding(value.binding)));
|
|
702
|
-
}
|
|
703
|
-
function isChoicePayload(value) {
|
|
704
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
705
|
-
}
|
|
706
|
-
function isChoiceBinding(value) {
|
|
707
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
708
|
-
return false;
|
|
709
|
-
return ((!("connection_hash" in value) || typeof value.connection_hash === "string") &&
|
|
710
|
-
(!("credential_hash" in value) || typeof value.credential_hash === "string"));
|
|
711
|
-
}
|
|
712
|
-
function assertManagedChoiceEnvelope(envelope, options) {
|
|
713
|
-
if (envelope.provider_id !== options.providerId || envelope.purpose !== options.purpose) {
|
|
714
|
-
throw new ProviderChoiceTokenError("invalid_payload", "Provider choice token payload is invalid.");
|
|
715
|
-
}
|
|
716
|
-
assertFreshProviderChoiceIssuedAt(envelope.issued_at_ms, {
|
|
717
|
-
// Clamp to the issuer's embedded TTL so a caller-supplied value cannot
|
|
718
|
-
// silently extend token validity past the deadline the issuer intended.
|
|
719
|
-
ttlMs: options.ttlMs != null ? Math.min(options.ttlMs, envelope.ttl_ms) : envelope.ttl_ms,
|
|
720
|
-
nowMs: options.nowMs,
|
|
721
|
-
futureToleranceMs: options.futureToleranceMs,
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
function createChoiceBinding(options) {
|
|
725
|
-
const connectionHash = options.options?.connection ? hashRequiredConnection(options) : undefined;
|
|
726
|
-
const credentialHash = options.options?.credentialKeys?.length
|
|
727
|
-
? hashCredentialKeys(options)
|
|
728
|
-
: undefined;
|
|
729
|
-
if (!connectionHash && !credentialHash)
|
|
730
|
-
return undefined;
|
|
731
|
-
return {
|
|
732
|
-
...(connectionHash ? { connection_hash: connectionHash } : {}),
|
|
733
|
-
...(credentialHash ? { credential_hash: credentialHash } : {}),
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
function hasRequestedChoiceBinding(options) {
|
|
737
|
-
return options?.connection === true || Boolean(options?.credentialKeys?.length);
|
|
738
|
-
}
|
|
739
|
-
function assertWordChoiceBindingMatches(options) {
|
|
740
|
-
const hasStoredBinding = Boolean(options.actual?.connection_hash || options.actual?.credential_hash);
|
|
741
|
-
const hasRequestedBinding = hasRequestedChoiceBinding(options.requested);
|
|
742
|
-
if (!hasStoredBinding && !hasRequestedBinding)
|
|
743
|
-
return;
|
|
744
|
-
if (hasStoredBinding !== hasRequestedBinding)
|
|
745
|
-
throw wordChoiceNotFoundError();
|
|
746
|
-
assertChoiceBindingMatches({
|
|
747
|
-
actual: options.actual,
|
|
748
|
-
expected: createChoiceBinding({
|
|
749
|
-
keys: options.resolveKeys(),
|
|
750
|
-
options: options.requested,
|
|
751
|
-
request: options.request,
|
|
752
|
-
credential: options.credential,
|
|
753
|
-
required: true,
|
|
754
|
-
}),
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
function hashRequiredConnection(options) {
|
|
758
|
-
const connectionId = options.request?.connectionId;
|
|
759
|
-
if (!connectionId) {
|
|
760
|
-
if (!options.required)
|
|
761
|
-
return undefined;
|
|
762
|
-
throw new ProviderError("Provider choice tokens require connection context.", {
|
|
763
|
-
code: "CHOICE_CONTEXT_REQUIRED",
|
|
764
|
-
category: "input_validation",
|
|
765
|
-
retryable: false,
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
return createHmac("sha256", options.keys.binding)
|
|
769
|
-
.update("connection")
|
|
770
|
-
.update("\0")
|
|
771
|
-
.update(connectionId)
|
|
772
|
-
.digest("base64url");
|
|
773
|
-
}
|
|
774
|
-
function hashCredentialKeys(options) {
|
|
775
|
-
const credentialKeys = options.options?.credentialKeys ?? [];
|
|
776
|
-
const material = credentialKeys.map((key) => {
|
|
777
|
-
const value = options.credential?.get(key);
|
|
778
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
779
|
-
throw new ProviderError("Provider choice tokens require configured credential binding.", {
|
|
780
|
-
code: "CHOICE_CONTEXT_REQUIRED",
|
|
781
|
-
category: "input_validation",
|
|
782
|
-
retryable: false,
|
|
783
|
-
details: { credentialKey: key },
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
return [key, value];
|
|
787
|
-
});
|
|
788
|
-
return createHmac("sha256", options.keys.binding)
|
|
789
|
-
.update("credential")
|
|
790
|
-
.update("\0")
|
|
791
|
-
.update(JSON.stringify(material))
|
|
792
|
-
.digest("base64url");
|
|
793
|
-
}
|
|
794
|
-
function assertChoiceBindingMatches(options) {
|
|
795
|
-
if (options.actual?.connection_hash !== options.expected?.connection_hash) {
|
|
796
|
-
throw new ProviderChoiceTokenError("invalid_binding", "Provider choice token connection binding is invalid.");
|
|
797
|
-
}
|
|
798
|
-
if (options.actual?.credential_hash !== options.expected?.credential_hash) {
|
|
799
|
-
throw new ProviderChoiceTokenError("invalid_binding", "Provider choice token credential binding is invalid.");
|
|
800
|
-
}
|
|
801
|
-
}
|