@wildo-ai/external-connectors-models 1.1.11 → 1.1.13
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/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/core/commons.external-connectors-models.schemas.d.ts +10 -0
- package/dist/esm/core/commons.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/core/commons.external-connectors-models.schemas.js +10 -0
- package/dist/esm/core/commons.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/core/oauth2.enums.d.ts.map +1 -1
- package/dist/esm/core/oauth2.enums.js.map +1 -1
- package/dist/esm/core/operation-classification.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/core/operation-classification.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/core/operations.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/core/operations.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/core/security.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/core/security.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/protocols/agent-client-protocol.external-connectors-models.schemas.d.ts +20 -46
- package/dist/esm/protocols/agent-client-protocol.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/agent-client-protocol.external-connectors-models.schemas.js +34 -5
- package/dist/esm/protocols/agent-client-protocol.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/billing.standard-interface.schemas.d.ts +56 -3
- package/dist/esm/protocols/billing.standard-interface.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/billing.standard-interface.schemas.js +15 -2
- package/dist/esm/protocols/billing.standard-interface.schemas.js.map +1 -1
- package/dist/esm/protocols/content-safety.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/content-safety.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/document-extraction.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/document-extraction.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/electronic-signature.external-connectors-models.schemas.d.ts +157 -0
- package/dist/esm/protocols/electronic-signature.external-connectors-models.schemas.d.ts.map +1 -0
- package/dist/esm/protocols/electronic-signature.external-connectors-models.schemas.js +134 -0
- package/dist/esm/protocols/electronic-signature.external-connectors-models.schemas.js.map +1 -0
- package/dist/esm/protocols/email-inbound.standard-interface.schemas.d.ts +85 -0
- package/dist/esm/protocols/email-inbound.standard-interface.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/email-inbound.standard-interface.schemas.js +46 -2
- package/dist/esm/protocols/email-inbound.standard-interface.schemas.js.map +1 -1
- package/dist/esm/protocols/email.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/email.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/email.standard-interface.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/email.standard-interface.schemas.js.map +1 -1
- package/dist/esm/protocols/embeddings.external-connectors-models.schemas.d.ts +49 -6
- package/dist/esm/protocols/embeddings.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/embeddings.external-connectors-models.schemas.js +89 -6
- package/dist/esm/protocols/embeddings.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/file-storage.standard-interface.d.ts +9 -2
- package/dist/esm/protocols/file-storage.standard-interface.d.ts.map +1 -1
- package/dist/esm/protocols/file-storage.standard-interface.js +6 -0
- package/dist/esm/protocols/file-storage.standard-interface.js.map +1 -1
- package/dist/esm/protocols/frontend.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/frontend.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/graphql-operations.external-connectors-models.schemas.d.ts +254 -0
- package/dist/esm/protocols/graphql-operations.external-connectors-models.schemas.d.ts.map +1 -0
- package/dist/esm/protocols/graphql-operations.external-connectors-models.schemas.js +480 -0
- package/dist/esm/protocols/graphql-operations.external-connectors-models.schemas.js.map +1 -0
- package/dist/esm/protocols/hooks-capability.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/hooks-capability.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/hooks.external-connectors-models.schemas.d.ts +95 -1
- package/dist/esm/protocols/hooks.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/hooks.external-connectors-models.schemas.js +110 -0
- package/dist/esm/protocols/hooks.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/image.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/image.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/llm.external-connectors-models.schemas.d.ts +95 -3
- package/dist/esm/protocols/llm.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/llm.external-connectors-models.schemas.js +147 -15
- package/dist/esm/protocols/llm.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/oauth2-provider-assurance.definitions.d.ts.map +1 -1
- package/dist/esm/protocols/oauth2-provider-assurance.definitions.js.map +1 -1
- package/dist/esm/protocols/oauth2-provider-assurance.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/oauth2-provider-assurance.schemas.js.map +1 -1
- package/dist/esm/protocols/oauth2.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/oauth2.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/provider-configuration-authoring.external-connectors-models.schemas.d.ts +52 -0
- package/dist/esm/protocols/provider-configuration-authoring.external-connectors-models.schemas.d.ts.map +1 -0
- package/dist/esm/protocols/provider-configuration-authoring.external-connectors-models.schemas.js +132 -0
- package/dist/esm/protocols/provider-configuration-authoring.external-connectors-models.schemas.js.map +1 -0
- package/dist/esm/protocols/provider-connection-parameters.external-connectors-models.d.ts +111 -0
- package/dist/esm/protocols/provider-connection-parameters.external-connectors-models.d.ts.map +1 -0
- package/dist/esm/protocols/provider-connection-parameters.external-connectors-models.js +203 -0
- package/dist/esm/protocols/provider-connection-parameters.external-connectors-models.js.map +1 -0
- package/dist/esm/protocols/provider-oauth2-token-endpoint-hosts.external-connectors-models.d.ts +6 -0
- package/dist/esm/protocols/provider-oauth2-token-endpoint-hosts.external-connectors-models.d.ts.map +1 -1
- package/dist/esm/protocols/provider-oauth2-token-endpoint-hosts.external-connectors-models.js +13 -7
- package/dist/esm/protocols/provider-oauth2-token-endpoint-hosts.external-connectors-models.js.map +1 -1
- package/dist/esm/protocols/provider-vendor-registration.external-connectors-models.d.ts +50 -0
- package/dist/esm/protocols/provider-vendor-registration.external-connectors-models.d.ts.map +1 -0
- package/dist/esm/protocols/provider-vendor-registration.external-connectors-models.js +30 -0
- package/dist/esm/protocols/provider-vendor-registration.external-connectors-models.js.map +1 -0
- package/dist/esm/protocols/rest-operations.external-connectors-models.schemas.d.ts +730 -17
- package/dist/esm/protocols/rest-operations.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/rest-operations.external-connectors-models.schemas.js +822 -20
- package/dist/esm/protocols/rest-operations.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/service-account-token.external-connectors-models.schemas.d.ts +73 -0
- package/dist/esm/protocols/service-account-token.external-connectors-models.schemas.d.ts.map +1 -0
- package/dist/esm/protocols/service-account-token.external-connectors-models.schemas.js +82 -0
- package/dist/esm/protocols/service-account-token.external-connectors-models.schemas.js.map +1 -0
- package/dist/esm/protocols/sms.external-connectors-models.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/sms.external-connectors-models.schemas.js.map +1 -1
- package/dist/esm/protocols/sms.standard-interface.schemas.d.ts +79 -1
- package/dist/esm/protocols/sms.standard-interface.schemas.d.ts.map +1 -1
- package/dist/esm/protocols/sms.standard-interface.schemas.js +93 -0
- package/dist/esm/protocols/sms.standard-interface.schemas.js.map +1 -1
- package/dist/esm/protocols/subject-erasure-effect.standard-interface.d.ts.map +1 -1
- package/dist/esm/protocols/subject-erasure-effect.standard-interface.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/dist/esm/.native.tsbuildinfo +0 -1
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
* OpenAPI document is ever read (a second mapper input), it feeds `ProviderRestOperation` like
|
|
44
44
|
* every other source.
|
|
45
45
|
*/
|
|
46
|
+
import { ExternalProvider_Connection_OwnerScope } from '../core/security.external-connectors-models.schemas.js';
|
|
46
47
|
/** @wildo_source:part:start external-connectors.models.rest-operation-contract facet:layer:shared facet:family:provider facet:audience:app-developer */
|
|
47
48
|
/** Where a declared parameter rides on the wire. */
|
|
48
49
|
export var ProviderRestParameterLocation;
|
|
@@ -109,6 +110,129 @@ export var ProviderRestPaginationStyle;
|
|
|
109
110
|
/** The vendor returns its own continuation URL (`@odata.nextLink`, `links.next`). */
|
|
110
111
|
ProviderRestPaginationStyle["LINK"] = "link";
|
|
111
112
|
})(ProviderRestPaginationStyle || (ProviderRestPaginationStyle = {}));
|
|
113
|
+
/**
|
|
114
|
+
* The schema a dot path into an operation's input names, or `undefined` when a segment names no declared field (#3399).
|
|
115
|
+
*
|
|
116
|
+
* Walked through Zod's own definition (`_zod.def`), not `instanceof`: a provider module may be built against another
|
|
117
|
+
* copy of Zod than the host's. Wrappers that leave the value's shape alone (optional, nullable, default, prefault,
|
|
118
|
+
* readonly, catch, nonoptional, a pipe's input, a lazy getter) are seen through. A union names the field when any of its
|
|
119
|
+
* options declares it, as a discriminated body does. Only an object's DECLARED fields count: a loose object admits other
|
|
120
|
+
* keys at runtime, and a picker for a key nobody declared is the typo this check exists to refuse.
|
|
121
|
+
*/
|
|
122
|
+
function providerRestInputSchemaAtPath(schema, segments) {
|
|
123
|
+
let current = schema;
|
|
124
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
125
|
+
const def = current?._zod?.def;
|
|
126
|
+
if (def === undefined)
|
|
127
|
+
return undefined;
|
|
128
|
+
switch (def.type) {
|
|
129
|
+
case 'optional':
|
|
130
|
+
case 'nullable':
|
|
131
|
+
case 'default':
|
|
132
|
+
case 'prefault':
|
|
133
|
+
case 'readonly':
|
|
134
|
+
case 'catch':
|
|
135
|
+
case 'nonoptional':
|
|
136
|
+
current = def.innerType;
|
|
137
|
+
continue;
|
|
138
|
+
case 'pipe':
|
|
139
|
+
current = def.in;
|
|
140
|
+
continue;
|
|
141
|
+
case 'lazy':
|
|
142
|
+
current = typeof def.getter === 'function' ? def.getter() : undefined;
|
|
143
|
+
continue;
|
|
144
|
+
case 'union': {
|
|
145
|
+
if (segments.length === 0)
|
|
146
|
+
return current;
|
|
147
|
+
const options = Array.isArray(def.options) ? def.options : [];
|
|
148
|
+
return options.map((option) => providerRestInputSchemaAtPath(option, segments)).find((found) => found !== undefined);
|
|
149
|
+
}
|
|
150
|
+
case 'object': {
|
|
151
|
+
if (segments.length === 0)
|
|
152
|
+
return current;
|
|
153
|
+
const shape = def.shape;
|
|
154
|
+
const [head, ...rest] = segments;
|
|
155
|
+
if (shape === undefined || head === undefined || !Object.hasOwn(shape, head))
|
|
156
|
+
return undefined;
|
|
157
|
+
return providerRestInputSchemaAtPath(shape[head], rest);
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return segments.length === 0 ? current : undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Why an operation's `inputOptions` cannot be honoured, or an empty list (#3399). Each key, and each `dependsOn` value,
|
|
167
|
+
* is a dot path into the operation's input (`project.key`, `CustomerRef.value`; a top-level field is a one-segment
|
|
168
|
+
* path), and must name a field the operation's `inputSchema` declares. Checked where a catalogue loads (the operation
|
|
169
|
+
* executor, an augmentation or supplement merge) and at publish, so a mistyped path is refused once, by name, instead of
|
|
170
|
+
* offering a picker that fills nothing. An operation without an `inputSchema` passes its input through unvalidated and
|
|
171
|
+
* declares no fields, so there is nothing to check its paths against; only their shape is.
|
|
172
|
+
*/
|
|
173
|
+
export function describeProviderRestOperationInputOptionsDefects(operation) {
|
|
174
|
+
const defects = [];
|
|
175
|
+
const check = (path, role) => {
|
|
176
|
+
const segments = path.split('.');
|
|
177
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
178
|
+
defects.push(`operation '${operation.operationId}': ${role} '${path}' is not a dot path of field names`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (operation.inputSchema !== undefined && providerRestInputSchemaAtPath(operation.inputSchema, segments) === undefined) {
|
|
182
|
+
defects.push(`operation '${operation.operationId}': ${role} '${path}' names no field of its inputSchema`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
for (const [inputPath, declaration] of Object.entries(operation.inputOptions ?? {})) {
|
|
186
|
+
check(inputPath, 'the inputOptions key');
|
|
187
|
+
for (const sourcePath of Object.values(declaration.dependsOn ?? {}))
|
|
188
|
+
check(sourcePath, `the dependsOn of '${inputPath}'`);
|
|
189
|
+
for (const fixed of Object.keys(declaration.input ?? {})) {
|
|
190
|
+
if (Object.hasOwn(declaration.dependsOn ?? {}, fixed)) {
|
|
191
|
+
defects.push(`operation '${operation.operationId}': the options of '${inputPath}' both fix and forward '${fixed}'`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const listPath of [declaration.itemsPath, ...Object.keys(declaration.itemWhere ?? {})]) {
|
|
195
|
+
if (listPath !== undefined && listPath.split('.').some((segment) => segment.length === 0)) {
|
|
196
|
+
defects.push(`operation '${operation.operationId}': the options of '${inputPath}' name '${listPath}', which is not a dot path`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return defects;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Why an operation's {@link ProviderRestOperationParameter.connectionFact} parameters cannot be honoured, or an empty
|
|
204
|
+
* list (#3397). Each must be a PATH parameter the path template uses, fed by nothing else (no `from`, `constantValue` or
|
|
205
|
+
* `multiSegment`), and the operation's `inputSchema` must not declare a field of its name: a caller is never asked for a
|
|
206
|
+
* value the connection supplies, and one it sends anyway is refused at the call. With `reportedAccountFacts` (the
|
|
207
|
+
* connection's OAuth2 declaration), each named fact must also be declared there with a `pathSegmentPattern`; without
|
|
208
|
+
* it the request compiler refuses the call, so a module walk passes them to catch it before any call.
|
|
209
|
+
*/
|
|
210
|
+
export function describeProviderRestOperationConnectionFactDefects(operation, reportedAccountFacts) {
|
|
211
|
+
const defects = [];
|
|
212
|
+
for (const parameter of operation.parameters ?? []) {
|
|
213
|
+
const factName = parameter.connectionFact;
|
|
214
|
+
if (factName === undefined)
|
|
215
|
+
continue;
|
|
216
|
+
const at = `operation '${operation.operationId}': the parameter '${parameter.name}' filled from the connection fact '${factName}'`;
|
|
217
|
+
if (parameter.in !== ProviderRestParameterLocation.PATH)
|
|
218
|
+
defects.push(`${at} is a ${parameter.in} parameter; only a PATH value may come from a connection fact`);
|
|
219
|
+
if (parameter.from !== undefined || parameter.constantValue !== undefined || parameter.multiSegment !== undefined) {
|
|
220
|
+
defects.push(`${at} also declares from, constantValue or multiSegment; its value comes from the connection alone`);
|
|
221
|
+
}
|
|
222
|
+
if (!operation.pathTemplate.includes(`{${parameter.name}}`))
|
|
223
|
+
defects.push(`${at} fills no '{${parameter.name}}' token of the path template`);
|
|
224
|
+
if (operation.inputSchema !== undefined && providerRestInputSchemaAtPath(operation.inputSchema, [parameter.name]) !== undefined) {
|
|
225
|
+
defects.push(`${at} is also declared by the inputSchema, which would ask callers for a value the connection supplies`);
|
|
226
|
+
}
|
|
227
|
+
if (reportedAccountFacts !== undefined) {
|
|
228
|
+
const fact = Object.hasOwn(reportedAccountFacts, factName) ? reportedAccountFacts[factName] : undefined;
|
|
229
|
+
if (fact?.pathSegmentPattern === undefined) {
|
|
230
|
+
defects.push(`${at}: the connection's OAuth2 contract declares no reported fact '${factName}' with a pathSegmentPattern`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return defects;
|
|
235
|
+
}
|
|
112
236
|
/** What a successful HTTP answer said about the call, read through a {@link ProviderRestApplicationOutcome}. */
|
|
113
237
|
export var ProviderRestApplicationOutcomeKind;
|
|
114
238
|
(function (ProviderRestApplicationOutcomeKind) {
|
|
@@ -151,6 +275,22 @@ export function describeProviderRestApplicationOutcomeDefects(outcome) {
|
|
|
151
275
|
export const PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER = '{tenant}';
|
|
152
276
|
/** A tenant value the token URL admits: a GUID or a DNS name. Nothing that can leave the URL's path segment. */
|
|
153
277
|
export const PROVIDER_CLIENT_CREDENTIALS_TENANT_SHAPE = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
|
278
|
+
/**
|
|
279
|
+
* The placeholder that BEGINS a client-credentials token URL whose token endpoint is on the deployment's own vendor
|
|
280
|
+
* instance (#3402): `{deploymentOrigin}/oauth2/v1/token`. Filled with the ORIGIN of the base URL the engine resolved for
|
|
281
|
+
* the REST contract (from `baseUrlHostEnvVarName` or `baseUrlEnvVarName`), never from a connection or a request.
|
|
282
|
+
*
|
|
283
|
+
* Okta is the case: every org has its own host (`<org>.okta.com`, or a custom domain), and its org authorization server's
|
|
284
|
+
* token endpoint is on that host. A fixed {@link ProviderRestClientCredentials.tokenHosts} list cannot admit a host only
|
|
285
|
+
* the operator knows, and a `{tenant}` value would be a second, separately typed copy of the host the operations already
|
|
286
|
+
* go to, which could disagree with it. Filling the token endpoint from the SAME resolved origin keeps them one value: the
|
|
287
|
+
* client's assertion is sent to the host the operator named, and to nothing a person connecting could choose.
|
|
288
|
+
*
|
|
289
|
+
* spec-cite: Okta Developer, "Implement OAuth for Okta with a service app"
|
|
290
|
+
* (https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/, fetched 2026-11-10) — the token
|
|
291
|
+
* request is `POST https://{yourOktaDomain}/oauth2/v1/token`, and the assertion's `aud` is that URL.
|
|
292
|
+
*/
|
|
293
|
+
export const PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER = '{deploymentOrigin}';
|
|
154
294
|
/** What a {@link ProviderClientCredentialsVerifiedFact} records: a value the person typed and the vendor accepted. */
|
|
155
295
|
export var ProviderClientCredentialsFactSource;
|
|
156
296
|
(function (ProviderClientCredentialsFactSource) {
|
|
@@ -159,6 +299,90 @@ export var ProviderClientCredentialsFactSource;
|
|
|
159
299
|
/** The tenant whose token endpoint issued it. */
|
|
160
300
|
ProviderClientCredentialsFactSource["TENANT"] = "tenant";
|
|
161
301
|
})(ProviderClientCredentialsFactSource || (ProviderClientCredentialsFactSource = {}));
|
|
302
|
+
/**
|
|
303
|
+
* The `grant_type` a client-credentials token request sends. Narrower than `OAuth2_GrantType` on purpose: these are the
|
|
304
|
+
* grants the client-credentials minter performs, each with nothing but the client's own credential and the declared
|
|
305
|
+
* parameters, and a member joins in the change that implements it.
|
|
306
|
+
*/
|
|
307
|
+
export var ProviderClientCredentialsGrantType;
|
|
308
|
+
(function (ProviderClientCredentialsGrantType) {
|
|
309
|
+
/**
|
|
310
|
+
* The OAuth 2.0 client-credentials grant: the default.
|
|
311
|
+
* spec-cite: RFC 6749 §4.4.2 — `grant_type`: "REQUIRED. Value MUST be set to "client_credentials"." Fetched 2026-10-06.
|
|
312
|
+
*/
|
|
313
|
+
ProviderClientCredentialsGrantType["CLIENT_CREDENTIALS"] = "client_credentials";
|
|
314
|
+
/**
|
|
315
|
+
* Zoom's server-to-server OAuth: the same exchange (a client authenticated with Basic, one token back, no refresh
|
|
316
|
+
* token) under Zoom's own grant name, with the account named in `account_id`.
|
|
317
|
+
* spec-cite: https://developers.zoom.us/docs/internal-apps/s2s-oauth/ — "Use the `account_credentials` grant type to
|
|
318
|
+
* generate an access token."; "Set up the body of the access token request using the `account_credentials`
|
|
319
|
+
* `grant_type` and your `account_id`." Fetched 2026-11-09.
|
|
320
|
+
*/
|
|
321
|
+
ProviderClientCredentialsGrantType["ACCOUNT_CREDENTIALS"] = "account_credentials";
|
|
322
|
+
})(ProviderClientCredentialsGrantType || (ProviderClientCredentialsGrantType = {}));
|
|
323
|
+
/**
|
|
324
|
+
* The algorithms a client assertion is signed with (#3402). The JWS names (RFC 7518 §3.1) of the two asymmetric
|
|
325
|
+
* families every vendor measured accepts: Okta lists "RS256, RS384, RS512, ES256, ES384, and ES512" for a service app's
|
|
326
|
+
* key. A member joins when a vendor needs it.
|
|
327
|
+
* spec-cite: RFC 7518 §3.1 — "RS256 | RSASSA-PKCS1-v1_5 using SHA-256" and "ES256 | ECDSA using P-256 and SHA-256";
|
|
328
|
+
* https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/ — the supported algorithms. Both
|
|
329
|
+
* fetched 2026-11-09.
|
|
330
|
+
*/
|
|
331
|
+
export var ProviderClientAssertionSigningAlgorithm;
|
|
332
|
+
(function (ProviderClientAssertionSigningAlgorithm) {
|
|
333
|
+
/** ECDSA on P-256 with SHA-256: a short key and signature, and the default to choose for a new key. */
|
|
334
|
+
ProviderClientAssertionSigningAlgorithm["ES256"] = "ES256";
|
|
335
|
+
/** RSASSA-PKCS1-v1_5 with SHA-256, for a vendor or key store that issues RSA keys. */
|
|
336
|
+
ProviderClientAssertionSigningAlgorithm["RS256"] = "RS256";
|
|
337
|
+
})(ProviderClientAssertionSigningAlgorithm || (ProviderClientAssertionSigningAlgorithm = {}));
|
|
338
|
+
/** The longest a client assertion may live: Okta refuses one whose `exp` is more than an hour away. */
|
|
339
|
+
export const PROVIDER_CLIENT_ASSERTION_MAXIMUM_LIFETIME_SECONDS = 3600;
|
|
340
|
+
/** How long a client assertion lives when the declaration says nothing: long enough for one request, and no more. */
|
|
341
|
+
export const PROVIDER_CLIENT_ASSERTION_DEFAULT_LIFETIME_SECONDS = 300;
|
|
342
|
+
/**
|
|
343
|
+
* The slot of a client-credentials declaration's own CREDENTIAL: the client secret, or the private key (#3402).
|
|
344
|
+
* `undefined` only for a declaration {@link describeProviderRestClientCredentialsDefects} refuses.
|
|
345
|
+
*/
|
|
346
|
+
export function providerClientCredentialsCredentialSlot(credentials) {
|
|
347
|
+
return credentials.clientSecretSecretRef ?? credentials.privateKeyJwt?.privateKeySecretRef;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The parameter names {@link ProviderRestClientCredentials.tokenRequestParameters} may not declare: each is written by
|
|
351
|
+
* the grant itself (the grant type, the client's credential, the scope, a client assertion), so a second value would
|
|
352
|
+
* either be ignored or override the declared one.
|
|
353
|
+
*/
|
|
354
|
+
export const PROVIDER_CLIENT_CREDENTIALS_RESERVED_PARAMETER_NAMES = [
|
|
355
|
+
'grant_type', 'client_id', 'client_secret', 'scope', 'client_assertion', 'client_assertion_type',
|
|
356
|
+
];
|
|
357
|
+
/**
|
|
358
|
+
* Whether the client-credentials door of a REST contract serves a connection of this owner scope (#3398).
|
|
359
|
+
*
|
|
360
|
+
* Alone, it serves every admitted connection: the application's own (from its environment) and an organization's (from
|
|
361
|
+
* the credential the organization typed, as a Teams bot). Beside `oauth2`, a person or an organization connects through
|
|
362
|
+
* the authorization-code exchange, and the client-credentials grant serves the APPLICATION's own account only, so that
|
|
363
|
+
* account renews itself instead of resting on a pasted token that expires within the hour.
|
|
364
|
+
*/
|
|
365
|
+
export function providerClientCredentialsServesOwnerScope(contract, ownerScope) {
|
|
366
|
+
if (contract.clientCredentials === undefined)
|
|
367
|
+
return false;
|
|
368
|
+
return contract.oauth2 === undefined || ownerScope === ExternalProvider_Connection_OwnerScope.APPLICATION;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The slot a connection of this owner scope AUTHENTICATES with, as the engine's REST access resolver reads it: the
|
|
372
|
+
* client-credentials grant's own credential (its client secret or private key) when that door serves the owner scope
|
|
373
|
+
* ({@link providerClientCredentialsServesOwnerScope}), else `apiKey.secretRef`, the stored or pasted token itself.
|
|
374
|
+
*
|
|
375
|
+
* For a contract declaring `clientCredentials` beside `oauth2` (Box, Zoom, #3398) the two differ by owner: the
|
|
376
|
+
* application's own account mints from the server app's secret, while a person's connection reads its exchanged token
|
|
377
|
+
* from `apiKey.secretRef`. A tool standing in for one connection (`wildo providers probe`, `connect`, the doctor's
|
|
378
|
+
* configuration checks, all APPLICATION-owned) asks this instead of reading `apiKey.secretRef`, so it reads the slot the
|
|
379
|
+
* engine would. `undefined` when the contract names neither.
|
|
380
|
+
*/
|
|
381
|
+
export function providerRestConnectionCredentialSlot(contract, ownerScope) {
|
|
382
|
+
return providerClientCredentialsServesOwnerScope(contract, ownerScope)
|
|
383
|
+
? providerClientCredentialsCredentialSlot(contract.clientCredentials)
|
|
384
|
+
: contract.apiKey?.secretRef;
|
|
385
|
+
}
|
|
162
386
|
/**
|
|
163
387
|
* Why a client-credentials declaration cannot be honoured, or an empty list. Checked where a provider is composed:
|
|
164
388
|
* a token URL that is not https, a token host that is not a DNS name or that the URL is not on, a tenant placeholder
|
|
@@ -167,9 +391,19 @@ export var ProviderClientCredentialsFactSource;
|
|
|
167
391
|
export function describeProviderRestClientCredentialsDefects(credentials) {
|
|
168
392
|
const defects = [];
|
|
169
393
|
const hasTenant = credentials.tokenUrl.includes(PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER);
|
|
170
|
-
|
|
394
|
+
// The deployment's origin forms the WHOLE origin or nothing (#3402): text before it, or anything but a path after it,
|
|
395
|
+
// would compose a host the operator never named.
|
|
396
|
+
const onDeploymentOrigin = credentials.tokenUrl.includes(PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER);
|
|
397
|
+
if (onDeploymentOrigin && !credentials.tokenUrl.startsWith(`${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}/`)) {
|
|
398
|
+
defects.push(`clientCredentials.tokenUrl carries ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER} other than as its whole origin, followed by a path`);
|
|
399
|
+
}
|
|
400
|
+
if (onDeploymentOrigin && hasTenant)
|
|
401
|
+
defects.push(`clientCredentials.tokenUrl carries both ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER} and ${PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER}; the token endpoint has one source`);
|
|
402
|
+
const filled = credentials.tokenUrl
|
|
403
|
+
.split(PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER).join('tenant.example')
|
|
404
|
+
.split(PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER).join('https://deployment.example');
|
|
171
405
|
if (/[{}]/.test(filled))
|
|
172
|
-
defects.push(`clientCredentials.tokenUrl '${credentials.tokenUrl}' carries a placeholder other than ${PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER}`);
|
|
406
|
+
defects.push(`clientCredentials.tokenUrl '${credentials.tokenUrl}' carries a placeholder other than ${PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER} and ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}`);
|
|
173
407
|
if (hasTenant && credentials.tenantSecretRef === undefined)
|
|
174
408
|
defects.push(`clientCredentials.tokenUrl carries ${PROVIDER_CLIENT_CREDENTIALS_TENANT_PLACEHOLDER} and no tenantSecretRef fills it`);
|
|
175
409
|
if (!hasTenant && credentials.tenantSecretRef !== undefined)
|
|
@@ -177,17 +411,66 @@ export function describeProviderRestClientCredentialsDefects(credentials) {
|
|
|
177
411
|
const host = /^https:\/\/([^/:?#]+)(?::\d+)?\//.exec(filled)?.[1]?.toLowerCase();
|
|
178
412
|
if (host === undefined)
|
|
179
413
|
defects.push(`clientCredentials.tokenUrl '${credentials.tokenUrl}' is not an https URL with a path`);
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
defects.push(`clientCredentials.tokenHosts names
|
|
414
|
+
if (onDeploymentOrigin) {
|
|
415
|
+
// The host is the deployment's, admitted where the engine resolves the operations' base URL; a fixed list would be
|
|
416
|
+
// a second, unchecked statement of it.
|
|
417
|
+
if (credentials.tokenHosts.length > 0)
|
|
418
|
+
defects.push(`clientCredentials.tokenHosts names hosts beside ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}; the token host is the deployment's, so declare none`);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
if (credentials.tokenHosts.length === 0)
|
|
422
|
+
defects.push('clientCredentials.tokenHosts admits no host');
|
|
423
|
+
for (const tokenHost of credentials.tokenHosts) {
|
|
424
|
+
if (!PROVIDER_OAUTH2_DNS_NAME_PATTERN.test(tokenHost.toLowerCase()))
|
|
425
|
+
defects.push(`clientCredentials.tokenHosts names '${tokenHost}', which is not a DNS name`);
|
|
426
|
+
}
|
|
427
|
+
if (host !== undefined && !credentials.tokenHosts.some((tokenHost) => tokenHost.toLowerCase() === host)) {
|
|
428
|
+
defects.push(`clientCredentials.tokenUrl is on '${host}', which tokenHosts does not admit`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (credentials.scope !== undefined && credentials.scope.trim() === '')
|
|
432
|
+
defects.push('clientCredentials.scope is empty; omit it to send none');
|
|
433
|
+
const grantTypes = Object.values(ProviderClientCredentialsGrantType);
|
|
434
|
+
if (credentials.grantType !== undefined && !grantTypes.includes(credentials.grantType)) {
|
|
435
|
+
defects.push(`clientCredentials.grantType ${JSON.stringify(credentials.grantType)} is none of ${grantTypes.join(', ')}`);
|
|
185
436
|
}
|
|
186
|
-
|
|
187
|
-
|
|
437
|
+
// One credential: a secret or a private key (#3402), and the method agrees with which.
|
|
438
|
+
if ((credentials.clientSecretSecretRef === undefined) === (credentials.privateKeyJwt === undefined)) {
|
|
439
|
+
defects.push('clientCredentials declares exactly one of clientSecretSecretRef and privateKeyJwt');
|
|
440
|
+
}
|
|
441
|
+
const methods = credentials.privateKeyJwt !== undefined
|
|
442
|
+
? [ProviderOAuth2ClientAuthenticationMethod.PRIVATE_KEY_JWT]
|
|
443
|
+
: [ProviderOAuth2ClientAuthenticationMethod.CLIENT_SECRET_POST, ProviderOAuth2ClientAuthenticationMethod.CLIENT_SECRET_BASIC];
|
|
444
|
+
if (credentials.tokenEndpointAuthMethod !== undefined && !methods.includes(credentials.tokenEndpointAuthMethod)) {
|
|
445
|
+
defects.push(`clientCredentials.tokenEndpointAuthMethod ${JSON.stringify(credentials.tokenEndpointAuthMethod)} is none of ${methods.join(', ')} for ${credentials.privateKeyJwt !== undefined ? 'a private key' : 'a client secret'}`);
|
|
446
|
+
}
|
|
447
|
+
if (credentials.privateKeyJwt !== undefined) {
|
|
448
|
+
const { privateKeySecretRef, algorithm, keyIdSecretRef, assertionLifetimeSeconds } = credentials.privateKeyJwt;
|
|
449
|
+
const algorithms = Object.values(ProviderClientAssertionSigningAlgorithm);
|
|
450
|
+
if (privateKeySecretRef.trim() === '')
|
|
451
|
+
defects.push('clientCredentials.privateKeyJwt.privateKeySecretRef names no slot');
|
|
452
|
+
if (!algorithms.includes(algorithm))
|
|
453
|
+
defects.push(`clientCredentials.privateKeyJwt.algorithm ${JSON.stringify(algorithm)} is none of ${algorithms.join(', ')}`);
|
|
454
|
+
if (keyIdSecretRef !== undefined && keyIdSecretRef.trim() === '')
|
|
455
|
+
defects.push('clientCredentials.privateKeyJwt.keyIdSecretRef names no slot');
|
|
456
|
+
if (assertionLifetimeSeconds !== undefined
|
|
457
|
+
&& !(Number.isInteger(assertionLifetimeSeconds) && assertionLifetimeSeconds > 0 && assertionLifetimeSeconds <= PROVIDER_CLIENT_ASSERTION_MAXIMUM_LIFETIME_SECONDS)) {
|
|
458
|
+
defects.push(`clientCredentials.privateKeyJwt.assertionLifetimeSeconds is not a whole number of seconds from 1 to ${PROVIDER_CLIENT_ASSERTION_MAXIMUM_LIFETIME_SECONDS}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const [name, parameter] of Object.entries(credentials.tokenRequestParameters ?? {})) {
|
|
462
|
+
if (!/^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(name))
|
|
463
|
+
defects.push(`clientCredentials.tokenRequestParameters names '${name}', which is not a parameter name`);
|
|
464
|
+
if (PROVIDER_CLIENT_CREDENTIALS_RESERVED_PARAMETER_NAMES.includes(name)) {
|
|
465
|
+
defects.push(`clientCredentials.tokenRequestParameters names '${name}', which the grant itself sends; declare it in its own field`);
|
|
466
|
+
}
|
|
467
|
+
const hasValue = typeof parameter.value === 'string';
|
|
468
|
+
const hasSlot = typeof parameter.secretRef === 'string';
|
|
469
|
+
if (hasValue === hasSlot)
|
|
470
|
+
defects.push(`clientCredentials.tokenRequestParameters '${name}' must declare exactly one of value and secretRef`);
|
|
471
|
+
else if ((parameter.value ?? parameter.secretRef ?? '').trim() === '')
|
|
472
|
+
defects.push(`clientCredentials.tokenRequestParameters '${name}' is empty`);
|
|
188
473
|
}
|
|
189
|
-
if (credentials.scope.trim() === '')
|
|
190
|
-
defects.push('clientCredentials.scope is empty');
|
|
191
474
|
for (const [name, fact] of Object.entries(credentials.verifiedAccountFacts ?? {})) {
|
|
192
475
|
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(name))
|
|
193
476
|
defects.push(`clientCredentials.verifiedAccountFacts names '${name}', which is not a fact name`);
|
|
@@ -200,6 +483,59 @@ export function describeProviderRestClientCredentialsDefects(credentials) {
|
|
|
200
483
|
}
|
|
201
484
|
return defects;
|
|
202
485
|
}
|
|
486
|
+
/**
|
|
487
|
+
* Why a REST contract's `clientCredentials` sits wrongly beside the contract's other authentication fields, or an empty
|
|
488
|
+
* list (#2663, #3398). Checked where a provider is composed, beside {@link describeProviderRestClientCredentialsDefects}.
|
|
489
|
+
*
|
|
490
|
+
* - beside `requestSigning`: two ways to authenticate one call;
|
|
491
|
+
* - alone, `apiKey.secretRef` must be the client secret's slot, the one whose absence means "not configured";
|
|
492
|
+
* - beside `oauth2` the grant serves the APPLICATION's own account only ({@link providerClientCredentialsServesOwnerScope}),
|
|
493
|
+
* so the module must admit that connection; `apiKey.secretRef` names the exchanged tokens' slot and may not be the
|
|
494
|
+
* client secret's (a person's token would be read from it); and `verifiedAccountFacts`, which only an organization's
|
|
495
|
+
* connection records, would record nothing.
|
|
496
|
+
* - a token URL on {@link PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER} (#3402) needs a base URL the
|
|
497
|
+
* DEPLOYMENT names (`baseUrlHostEnvVarName` or `baseUrlEnvVarName`), and may serve the APPLICATION's connection only:
|
|
498
|
+
* the deployment's instance is the application's own (its Okta org), so an organization minting there would
|
|
499
|
+
* authenticate against the application's vendor tenant, never its own.
|
|
500
|
+
*/
|
|
501
|
+
export function describeProviderRestClientCredentialsPlacementDefects(contract, admitsApplicationConnection,
|
|
502
|
+
/** Whether the module admits an ORGANIZATION or USER connection. Omitted, `false`. */
|
|
503
|
+
admitsNonApplicationConnection = false) {
|
|
504
|
+
const credentials = contract.clientCredentials;
|
|
505
|
+
if (credentials === undefined)
|
|
506
|
+
return [];
|
|
507
|
+
const defects = [];
|
|
508
|
+
if (contract.requestSigning !== undefined) {
|
|
509
|
+
defects.push('declares clientCredentials beside requestSigning; a call authenticates one way');
|
|
510
|
+
}
|
|
511
|
+
if (credentials.tokenUrl.includes(PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER)) {
|
|
512
|
+
if (contract.baseUrlHostEnvVarName === undefined && contract.baseUrlEnvVarName === undefined) {
|
|
513
|
+
defects.push(`clientCredentials.tokenUrl is on ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}, and the contract's base URL is not one the deployment names (baseUrlHostEnvVarName or baseUrlEnvVarName)`);
|
|
514
|
+
}
|
|
515
|
+
if (!admitsApplicationConnection) {
|
|
516
|
+
defects.push(`clientCredentials.tokenUrl is on ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}, which serves the application's own connection only, and the module admits none`);
|
|
517
|
+
}
|
|
518
|
+
if (contract.oauth2 === undefined && admitsNonApplicationConnection) {
|
|
519
|
+
defects.push(`clientCredentials.tokenUrl is on ${PROVIDER_CLIENT_CREDENTIALS_DEPLOYMENT_ORIGIN_PLACEHOLDER}, the application's own vendor instance, and the module admits an organization or user connection that would mint there`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const credentialSlot = providerClientCredentialsCredentialSlot(credentials);
|
|
523
|
+
if (contract.oauth2 === undefined) {
|
|
524
|
+
if (contract.apiKey.secretRef !== credentialSlot) {
|
|
525
|
+
defects.push(`apiKey.secretRef '${contract.apiKey.secretRef}' is not the client's credential slot '${credentialSlot}' (its secret or private key); that is the slot whose absence means not configured`);
|
|
526
|
+
}
|
|
527
|
+
return defects;
|
|
528
|
+
}
|
|
529
|
+
if (!admitsApplicationConnection)
|
|
530
|
+
defects.push('declares clientCredentials beside oauth2 and admits no APPLICATION connection, the only one that grant serves');
|
|
531
|
+
if (contract.apiKey.secretRef === credentialSlot) {
|
|
532
|
+
defects.push(`apiKey.secretRef '${contract.apiKey.secretRef}' is the client-credentials secret or key; beside oauth2 it names the slot of the exchanged access tokens`);
|
|
533
|
+
}
|
|
534
|
+
if (credentials.verifiedAccountFacts !== undefined) {
|
|
535
|
+
defects.push('declares clientCredentials.verifiedAccountFacts beside oauth2; an organization connects through oauth2, so no client-credentials fact is ever recorded');
|
|
536
|
+
}
|
|
537
|
+
return defects;
|
|
538
|
+
}
|
|
203
539
|
function readDotPath(body, path) {
|
|
204
540
|
let current = body;
|
|
205
541
|
for (const segment of path.split('.')) {
|
|
@@ -350,17 +686,15 @@ export var ProviderOAuth2ScopeMeaning;
|
|
|
350
686
|
*/
|
|
351
687
|
ProviderOAuth2ScopeMeaning["PERMISSION"] = "PERMISSION";
|
|
352
688
|
})(ProviderOAuth2ScopeMeaning || (ProviderOAuth2ScopeMeaning = {}));
|
|
353
|
-
/**
|
|
689
|
+
/**
|
|
690
|
+
* Whether `host` is admitted by one audience's hosts: an exact value, or `<labels><suffix>` for a declared suffix, with
|
|
691
|
+
* no more labels than `hostSuffixMaxLabels` allows (#3640).
|
|
692
|
+
*/
|
|
354
693
|
export function providerOAuth2AudienceAdmitsHost(audience, host) {
|
|
355
694
|
const candidate = host.toLowerCase();
|
|
356
695
|
if (audience.hosts.values.some((value) => value.toLowerCase() === candidate))
|
|
357
696
|
return true;
|
|
358
|
-
return (audience.hosts.hostSuffixes ?? []).some((suffix) =>
|
|
359
|
-
const lowered = suffix.toLowerCase();
|
|
360
|
-
if (!candidate.endsWith(lowered) || candidate.length === lowered.length)
|
|
361
|
-
return false;
|
|
362
|
-
return PROVIDER_OAUTH2_DNS_NAME_PATTERN.test(candidate.slice(0, candidate.length - lowered.length));
|
|
363
|
-
});
|
|
697
|
+
return (audience.hosts.hostSuffixes ?? []).some((suffix) => providerHostIsUnderSuffix(candidate, suffix, audience.hosts.hostSuffixMaxLabels));
|
|
364
698
|
}
|
|
365
699
|
/**
|
|
366
700
|
* The ONE audience whose hosts admit `host`, or `undefined` when none does. The declaration guarantees at most one
|
|
@@ -527,6 +861,76 @@ export var ProviderOAuth2PkceSupport;
|
|
|
527
861
|
/** The vendor refuses PKCE from a confidential client (Slack): neither is sent. */
|
|
528
862
|
ProviderOAuth2PkceSupport["NOT_ACCEPTED"] = "NOT_ACCEPTED";
|
|
529
863
|
})(ProviderOAuth2PkceSupport || (ProviderOAuth2PkceSupport = {}));
|
|
864
|
+
/**
|
|
865
|
+
* How this application's client authenticates to a vendor's token and revocation endpoints. See
|
|
866
|
+
* {@link ProviderRestOAuth2.tokenEndpointAuthMethod}.
|
|
867
|
+
*
|
|
868
|
+
* The values are the names the "OAuth Token Endpoint Authentication Methods" registry gives these two methods, which
|
|
869
|
+
* is what OpenID Connect and dynamic client registration call `token_endpoint_auth_method`, so a vendor's discovery
|
|
870
|
+
* document (`token_endpoint_auth_methods_supported`) can be compared with a declaration by eye.
|
|
871
|
+
* spec-cite: RFC 7591 §2 — `token_endpoint_auth_method`: "client_secret_post": "The client uses the HTTP POST parameters
|
|
872
|
+
* as defined in OAuth 2.0, Section 2.3.1."; "client_secret_basic": "The client uses HTTP Basic as defined in OAuth 2.0,
|
|
873
|
+
* Section 2.3.1." Fetched 2026-11-06.
|
|
874
|
+
*
|
|
875
|
+
* Narrower than `OAuth2_TokenEndpoint_AuthMethod` on purpose: that vocabulary also names `client_secret_jwt` and
|
|
876
|
+
* `none`, which nothing here performs. `private_key_jwt` joined with #3402, performed by the client-credentials grant
|
|
877
|
+
* only; the connected-account exchange refuses it at startup. A member nothing honours would
|
|
878
|
+
* be a declaration that looks like a client-authentication choice and changes nothing, so each method joins this
|
|
879
|
+
* vocabulary in the change that implements it.
|
|
880
|
+
*/
|
|
881
|
+
export var ProviderOAuth2ClientAuthenticationMethod;
|
|
882
|
+
(function (ProviderOAuth2ClientAuthenticationMethod) {
|
|
883
|
+
/**
|
|
884
|
+
* `client_id` and `client_secret` as parameters of the request body: the engine's behaviour when nothing is declared,
|
|
885
|
+
* and what most vendors' own documentation shows. RFC 6749 calls it "NOT RECOMMENDED", which is why a vendor that
|
|
886
|
+
* documents Basic declares {@link CLIENT_SECRET_BASIC} rather than being met with this.
|
|
887
|
+
* spec-cite: RFC 6749 §2.3.1 — sending the two parameters in the body "is NOT RECOMMENDED and SHOULD be limited to
|
|
888
|
+
* clients unable to directly utilize the HTTP Basic authentication scheme". Fetched 2026-11-06.
|
|
889
|
+
*/
|
|
890
|
+
ProviderOAuth2ClientAuthenticationMethod["CLIENT_SECRET_POST"] = "client_secret_post";
|
|
891
|
+
/**
|
|
892
|
+
* `Authorization: Basic base64(client_id ":" client_secret)`, each half form-urlencoded BEFORE base64, and neither in
|
|
893
|
+
* the body: one credential on one channel. Every authorization server must accept it (Zoom, Notion and Intuit document
|
|
894
|
+
* it as their client authentication).
|
|
895
|
+
* spec-cite: RFC 6749 §2.3.1 — "The client identifier is encoded using the "application/x-www-form-urlencoded"
|
|
896
|
+
* encoding algorithm per Appendix B", and so is the password, before both become the Basic user-id and password; "The
|
|
897
|
+
* authorization server MUST support the HTTP Basic authentication scheme". Appendix B encodes UTF-8 first, so `£` is
|
|
898
|
+
* `%C2%A3` and a space is `+`. Fetched 2026-11-06.
|
|
899
|
+
*/
|
|
900
|
+
ProviderOAuth2ClientAuthenticationMethod["CLIENT_SECRET_BASIC"] = "client_secret_basic";
|
|
901
|
+
/**
|
|
902
|
+
* A JWT the client signs with its own private key, sent as `client_assertion` (#3402). Performed by the
|
|
903
|
+
* client-credentials grant only ({@link ProviderRestClientCredentials.privateKeyJwt}); the connected-account exchange
|
|
904
|
+
* does not sign assertions, so `oauth2.tokenEndpointAuthMethod` refuses it at startup.
|
|
905
|
+
* spec-cite: OpenID Connect Core 1.0 §9 — `private_key_jwt`: "Clients that have registered a public key sign a JWT
|
|
906
|
+
* using that key." Fetched 2026-11-09 from https://openid.net/specs/openid-connect-core-1_0.html.
|
|
907
|
+
*/
|
|
908
|
+
ProviderOAuth2ClientAuthenticationMethod["PRIVATE_KEY_JWT"] = "private_key_jwt";
|
|
909
|
+
})(ProviderOAuth2ClientAuthenticationMethod || (ProviderOAuth2ClientAuthenticationMethod = {}));
|
|
910
|
+
/**
|
|
911
|
+
* How the parameters of a request to a vendor's token or revocation endpoint are written in its body. See
|
|
912
|
+
* {@link ProviderRestOAuth2.tokenRequestBodyEncoding} and {@link ProviderRestOAuth2.revocationRequestBodyEncoding}.
|
|
913
|
+
*
|
|
914
|
+
* The parameters are the same in both encodings (`grant_type`, `code`, `refresh_token`, `token`, …); only their
|
|
915
|
+
* serialization and the `Content-Type` change.
|
|
916
|
+
*/
|
|
917
|
+
export var ProviderOAuth2RequestBodyEncoding;
|
|
918
|
+
(function (ProviderOAuth2RequestBodyEncoding) {
|
|
919
|
+
/**
|
|
920
|
+
* `application/x-www-form-urlencoded`, which RFC 6749 and RFC 7009 specify: the default for both requests.
|
|
921
|
+
* spec-cite: RFC 6749 §4.1.3 (the client makes the token request "by sending the following parameters using the
|
|
922
|
+
* "application/x-www-form-urlencoded" format"; §6 does the same for a refresh), and RFC 7009 §2.1 (the revocation
|
|
923
|
+
* request uses "the "application/x-www-form-urlencoded" format in the HTTP request entity-body"). Fetched 2026-11-06.
|
|
924
|
+
*/
|
|
925
|
+
ProviderOAuth2RequestBodyEncoding["FORM"] = "FORM";
|
|
926
|
+
/**
|
|
927
|
+
* `application/json`, an object whose members are the same parameters as string values. A deviation some vendors
|
|
928
|
+
* document: Notion's token endpoint takes only JSON, and Intuit's own client library revokes with a JSON body.
|
|
929
|
+
* spec-cite: https://developers.notion.com/docs/authorization — the token request's `Content-Type: application/json`,
|
|
930
|
+
* fetched 2026-11-06.
|
|
931
|
+
*/
|
|
932
|
+
ProviderOAuth2RequestBodyEncoding["JSON"] = "JSON";
|
|
933
|
+
})(ProviderOAuth2RequestBodyEncoding || (ProviderOAuth2RequestBodyEncoding = {}));
|
|
530
934
|
/**
|
|
531
935
|
* A `scope` list encoding that deviates from RFC 6749's space-delimited list, in either direction: the token answer
|
|
532
936
|
* ({@link ProviderRestOAuth2.grantedScopesDelimiter}) or the authorize request ({@link ProviderRestOAuth2.authorizeScopesDelimiter}).
|
|
@@ -544,6 +948,87 @@ export var ProviderOAuth2ScopeListDelimiter;
|
|
|
544
948
|
*/
|
|
545
949
|
ProviderOAuth2ScopeListDelimiter["COMMA"] = "COMMA";
|
|
546
950
|
})(ProviderOAuth2ScopeListDelimiter || (ProviderOAuth2ScopeListDelimiter = {}));
|
|
951
|
+
/**
|
|
952
|
+
* What makes a client-authentication declaration unusable (see {@link ProviderRestOAuth2.tokenEndpointAuthMethod}):
|
|
953
|
+
*
|
|
954
|
+
* - a method or an encoding outside its vocabulary. The request builder tests for the declared member, so an unknown
|
|
955
|
+
* value (a misspelling in a module written without types) would silently fall back to the default and send the
|
|
956
|
+
* secret the way the vendor said not to;
|
|
957
|
+
* - a revocation encoding with no revocation URL, a setting that changes nothing.
|
|
958
|
+
*/
|
|
959
|
+
export function describeProviderOAuth2ClientAuthenticationDefects(oauth2) {
|
|
960
|
+
const defects = [];
|
|
961
|
+
// The exchange signs no assertion: a key-based client (#3402) authenticates only on the client-credentials grant.
|
|
962
|
+
const methods = [ProviderOAuth2ClientAuthenticationMethod.CLIENT_SECRET_POST, ProviderOAuth2ClientAuthenticationMethod.CLIENT_SECRET_BASIC];
|
|
963
|
+
const encodings = Object.values(ProviderOAuth2RequestBodyEncoding);
|
|
964
|
+
if (oauth2.tokenEndpointAuthMethod !== undefined && !methods.includes(oauth2.tokenEndpointAuthMethod)) {
|
|
965
|
+
defects.push(`declares tokenEndpointAuthMethod ${JSON.stringify(oauth2.tokenEndpointAuthMethod)}, which is none of ${methods.join(', ')}`);
|
|
966
|
+
}
|
|
967
|
+
for (const [field, value] of [['tokenRequestBodyEncoding', oauth2.tokenRequestBodyEncoding], ['revocationRequestBodyEncoding', oauth2.revocationRequestBodyEncoding]]) {
|
|
968
|
+
if (value !== undefined && !encodings.includes(value))
|
|
969
|
+
defects.push(`declares ${field} ${JSON.stringify(value)}, which is none of ${encodings.join(', ')}`);
|
|
970
|
+
}
|
|
971
|
+
if (oauth2.revocationRequestBodyEncoding !== undefined && oauth2.rfc7009RevocationUrl === undefined) {
|
|
972
|
+
defects.push('declares revocationRequestBodyEncoding without rfc7009RevocationUrl, so no revocation request uses it');
|
|
973
|
+
}
|
|
974
|
+
return defects;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* The parameter names the token request writes itself, which {@link ProviderRestOAuth2.codeExchangeParameters} may not
|
|
978
|
+
* name: the exchange's own parameters (RFC 6749 §4.1.3), the client's credentials (§2.3.1) and assertion (RFC 7523 §2.2),
|
|
979
|
+
* and the refresh's (§6). A declared value for one of them would change what the request IS.
|
|
980
|
+
* semantic-literal-boundary: OAuth2 wire parameter names, as RFC 6749 and RFC 7523 spell them.
|
|
981
|
+
*/
|
|
982
|
+
const PROVIDER_OAUTH2_ENGINE_WRITTEN_TOKEN_PARAMETERS = new Set([
|
|
983
|
+
'grant_type', 'code', 'redirect_uri', 'code_verifier', 'client_id', 'client_secret', 'client_assertion', 'client_assertion_type', 'refresh_token', 'scope',
|
|
984
|
+
]);
|
|
985
|
+
/** An OAuth2 error code's shape (RFC 6749 §5.2: `error` values are `%x20-21 / %x23-5B / %x5D-7E`, no space in practice). */
|
|
986
|
+
const PROVIDER_OAUTH2_ERROR_CODE_SHAPE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
987
|
+
/** A token-request parameter name a declaration may add: a plain identifier, so it cannot smuggle structure into a body. */
|
|
988
|
+
const PROVIDER_OAUTH2_EXTRA_PARAMETER_NAME = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
|
|
989
|
+
/**
|
|
990
|
+
* What makes a token-request declaration unusable (follow-up to #3392), checked where the provider registry composes:
|
|
991
|
+
*
|
|
992
|
+
* - a {@link ProviderRestOAuth2.codeExchangeParameters} name that is not a plain identifier, that names a parameter the
|
|
993
|
+
* engine writes itself, or whose value is empty;
|
|
994
|
+
* - a {@link ProviderRestOAuth2.refreshRefusals} entry whose code is not code-shaped (it would never match), is
|
|
995
|
+
* `invalid_grant` (already a refusal, so the entry says nothing) or `invalid_client` (the application's registration,
|
|
996
|
+
* never one person's grant), or whose description is empty.
|
|
997
|
+
*/
|
|
998
|
+
export function describeProviderOAuth2TokenRequestDefects(oauth2) {
|
|
999
|
+
const defects = [];
|
|
1000
|
+
for (const [name, value] of Object.entries(oauth2.codeExchangeParameters ?? {})) {
|
|
1001
|
+
if (!PROVIDER_OAUTH2_EXTRA_PARAMETER_NAME.test(name))
|
|
1002
|
+
defects.push(`declares the code-exchange parameter ${JSON.stringify(name)}, which is not a plain parameter name`);
|
|
1003
|
+
else if (PROVIDER_OAUTH2_ENGINE_WRITTEN_TOKEN_PARAMETERS.has(name))
|
|
1004
|
+
defects.push(`declares the code-exchange parameter '${name}', which the engine writes itself`);
|
|
1005
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
1006
|
+
defects.push(`declares the code-exchange parameter ${JSON.stringify(name)} with no value`);
|
|
1007
|
+
}
|
|
1008
|
+
for (const refusal of oauth2.refreshRefusals ?? []) {
|
|
1009
|
+
if (typeof refusal.error !== 'string' || !PROVIDER_OAUTH2_ERROR_CODE_SHAPE.test(refusal.error)) {
|
|
1010
|
+
defects.push(`declares the refresh refusal ${JSON.stringify(refusal.error)}, which is not an OAuth2 error code and would never match`);
|
|
1011
|
+
}
|
|
1012
|
+
else if (refusal.error === 'invalid_grant') {
|
|
1013
|
+
defects.push('declares the refresh refusal \'invalid_grant\', which already retires a connection');
|
|
1014
|
+
}
|
|
1015
|
+
else if (refusal.error === 'invalid_client') {
|
|
1016
|
+
defects.push('declares the refresh refusal \'invalid_client\', which names the application\'s registration, not a person\'s grant');
|
|
1017
|
+
}
|
|
1018
|
+
if (refusal.errorDescription !== undefined && refusal.errorDescription.trim().length === 0) {
|
|
1019
|
+
defects.push(`declares the refresh refusal '${refusal.error}' with an empty errorDescription`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return defects;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether a refresh's refusal is one the provider declares as a dead refresh token ({@link ProviderRestOAuth2.refreshRefusals}).
|
|
1026
|
+
* The one reading, shared by the refresh and its tests.
|
|
1027
|
+
*/
|
|
1028
|
+
export function providerOAuth2RefreshRefusalIsDeclared(oauth2, answer) {
|
|
1029
|
+
return (oauth2.refreshRefusals ?? []).some((refusal) => refusal.error === answer.error
|
|
1030
|
+
&& (refusal.errorDescription === undefined || refusal.errorDescription === answer.errorDescription));
|
|
1031
|
+
}
|
|
547
1032
|
/**
|
|
548
1033
|
* The granted scopes a token answer's `scope` string names, read with the vendor's declared delimiter: whitespace (RFC
|
|
549
1034
|
* 6749 §5.1) when none is declared, a comma for {@link ProviderOAuth2ScopeListDelimiter.COMMA}.
|
|
@@ -595,6 +1080,8 @@ export function describeProviderOAuth2AudienceDefects(oauth2) {
|
|
|
595
1080
|
if (!PROVIDER_OAUTH2_DNS_NAME_PATTERN.test(value.toLowerCase()))
|
|
596
1081
|
defects.push(`audience '${id}' declares the host '${value}', which is not a DNS name`);
|
|
597
1082
|
}
|
|
1083
|
+
for (const defect of describeProviderHostSuffixMaxLabelsDefects(audience.hosts.hostSuffixMaxLabels, audience.hosts.hostSuffixes))
|
|
1084
|
+
defects.push(`audience '${id}': ${defect}`);
|
|
598
1085
|
if (audience.mintScopes.length === 0)
|
|
599
1086
|
defects.push(`audience '${id}' declares no mint scope`);
|
|
600
1087
|
for (const scope of audience.mintScopes) {
|
|
@@ -618,7 +1105,7 @@ export function describeProviderOAuth2AudienceDefects(oauth2) {
|
|
|
618
1105
|
const [rightId, rightAudience] = entries[right];
|
|
619
1106
|
const overlap = leftAudience.hosts.values.some((host) => providerOAuth2AudienceAdmitsHost(rightAudience, host))
|
|
620
1107
|
|| rightAudience.hosts.values.some((host) => providerOAuth2AudienceAdmitsHost(leftAudience, host))
|
|
621
|
-
|| (leftAudience.hosts.hostSuffixes ?? []).some((suffix) => (rightAudience.hosts.hostSuffixes ?? []).some((other) => suffix
|
|
1108
|
+
|| (leftAudience.hosts.hostSuffixes ?? []).some((suffix) => (rightAudience.hosts.hostSuffixes ?? []).some((other) => providerHostSuffixesOverlap({ suffix, ...(leftAudience.hosts.hostSuffixMaxLabels === undefined ? {} : { maxLabels: leftAudience.hosts.hostSuffixMaxLabels }) }, { suffix: other, ...(rightAudience.hosts.hostSuffixMaxLabels === undefined ? {} : { maxLabels: rightAudience.hosts.hostSuffixMaxLabels }) })));
|
|
622
1109
|
if (overlap)
|
|
623
1110
|
defects.push(`audiences '${leftId}' and '${rightId}' admit a common host; a request host must select one token`);
|
|
624
1111
|
}
|
|
@@ -639,7 +1126,122 @@ export var ProviderOAuth2ReportedFactSource;
|
|
|
639
1126
|
* spec-cite: OpenID Connect Core 1.0 §3.1.3.7, item 6.
|
|
640
1127
|
*/
|
|
641
1128
|
ProviderOAuth2ReportedFactSource["ID_TOKEN_CLAIMS"] = "id_token_claims";
|
|
1129
|
+
/**
|
|
1130
|
+
* A query parameter of the AUTHORIZATION REDIRECT that brings the person back with the code (#3397): Intuit's
|
|
1131
|
+
* `realmId`, the QuickBooks company the person chose on the consent screen. `responsePath` is the parameter's name,
|
|
1132
|
+
* one segment. Read by the callback leg, kept with the attempt until the completion leg redeems the code, and recorded
|
|
1133
|
+
* only from that exchange: a refresh has no redirect, so it never restates such a fact and the stored value stands.
|
|
1134
|
+
*
|
|
1135
|
+
* The weakest of the three sources, and restricted for it. The redirect reaches this application through the
|
|
1136
|
+
* browser of the person who authorized, so that person could change the value before it arrives, exactly as they
|
|
1137
|
+
* could type an answer. Such a fact is therefore admitted only where a wrong value harms nobody but the person who
|
|
1138
|
+
* forged it: as a PATH value ({@link ProviderOAuth2ReportedAccountFact.pathSegmentPattern}) of a vendor that checks it
|
|
1139
|
+
* against the token on every call (Intuit answers a realm the token was not granted for with an authorization fault).
|
|
1140
|
+
* It may never be an `origin`, fill a base URL host, or route inbound deliveries, since each of those decides where a
|
|
1141
|
+
* credential goes or which organization receives a message; `describeProviderOAuth2ReportedFactUseDefects` refuses it.
|
|
1142
|
+
*/
|
|
1143
|
+
ProviderOAuth2ReportedFactSource["AUTHORIZATION_REDIRECT_QUERY"] = "authorization_redirect_query";
|
|
1144
|
+
/**
|
|
1145
|
+
* The JSON answer of the vendor's USERINFO endpoint ({@link ProviderRestOAuth2.userInfoUrl}, #3397), fetched with the
|
|
1146
|
+
* access token the authorization just minted: DocuSign's `accounts[…].account_id` and `base_uri`. As trustworthy as
|
|
1147
|
+
* the token answer (the vendor answers over TLS, to a request the person's own token authorized), so it may be placed
|
|
1148
|
+
* wherever a token fact may. Read at the authorization-code exchange only: a refresh does not call the endpoint, so it
|
|
1149
|
+
* never restates such a fact and the stored value stands until the person connects again.
|
|
1150
|
+
*/
|
|
1151
|
+
ProviderOAuth2ReportedFactSource["USERINFO_RESPONSE"] = "userinfo_response";
|
|
642
1152
|
})(ProviderOAuth2ReportedFactSource || (ProviderOAuth2ReportedFactSource = {}));
|
|
1153
|
+
/** What reading a {@link ProviderOAuth2ReportedAccountFact.responsePath} out of a vendor's answer found (#3397). */
|
|
1154
|
+
export var ProviderReportedFactReadOutcome;
|
|
1155
|
+
(function (ProviderReportedFactReadOutcome) {
|
|
1156
|
+
/** A non-empty string or a finite number stood at the path; `value` is its text. */
|
|
1157
|
+
ProviderReportedFactReadOutcome["FOUND"] = "FOUND";
|
|
1158
|
+
/** Nothing usable stood at the path: a missing key, a `null`, an object, an empty string. The fact is not reported. */
|
|
1159
|
+
ProviderReportedFactReadOutcome["ABSENT"] = "ABSENT";
|
|
1160
|
+
/**
|
|
1161
|
+
* A selection segment (`accounts[is_default=true]`) met an array in which NOT EXACTLY ONE element matched. The vendor's
|
|
1162
|
+
* answer does not name one element, so no fact is read from it: guessing the first would address a request to an
|
|
1163
|
+
* account nobody chose. `matched` is how many did.
|
|
1164
|
+
*/
|
|
1165
|
+
ProviderReportedFactReadOutcome["SELECTION_NOT_SINGLE"] = "SELECTION_NOT_SINGLE";
|
|
1166
|
+
})(ProviderReportedFactReadOutcome || (ProviderReportedFactReadOutcome = {}));
|
|
1167
|
+
/** `name` or `name[field=value]`; no `.`, `[`, `]` or `=` inside a name, a field or a value. */
|
|
1168
|
+
const PROVIDER_REPORTED_FACT_PATH_SEGMENT_PATTERN = /^([^.[\]=]+)(?:\[([^.[\]=]+)=([^.[\]=]*)\])?$/;
|
|
1169
|
+
/**
|
|
1170
|
+
* The steps of a reported fact's `responsePath`, or `undefined` when it is not one (#3397). A path is dot-separated;
|
|
1171
|
+
* each step is an own property name, optionally followed by ONE selection `[field=value]` that picks, out of the array
|
|
1172
|
+
* at that property, the single element whose `field` reads as `value`: `accounts[is_default=true].account_id`.
|
|
1173
|
+
*
|
|
1174
|
+
* Names, fields and values may not contain `.`, `[`, `]` or `=`; a value may be empty. Kept this small on purpose: it
|
|
1175
|
+
* addresses one element by one stated condition, which is what a vendor documenting "the default account" means, and
|
|
1176
|
+
* nothing that would need a query language.
|
|
1177
|
+
*/
|
|
1178
|
+
export function parseProviderReportedFactPath(path) {
|
|
1179
|
+
if (typeof path !== 'string' || path.length === 0)
|
|
1180
|
+
return undefined;
|
|
1181
|
+
const segments = [];
|
|
1182
|
+
for (const text of path.split('.')) {
|
|
1183
|
+
const match = PROVIDER_REPORTED_FACT_PATH_SEGMENT_PATTERN.exec(text);
|
|
1184
|
+
if (match === null)
|
|
1185
|
+
return undefined;
|
|
1186
|
+
segments.push(match[2] === undefined ? { key: match[1] } : { key: match[1], select: { field: match[2], equals: match[3] } });
|
|
1187
|
+
}
|
|
1188
|
+
return segments;
|
|
1189
|
+
}
|
|
1190
|
+
/** The text a scalar reads as: a non-empty string, a finite number's decimal text, a boolean's `true`/`false`. */
|
|
1191
|
+
function providerReportedScalarText(value, admitBoolean) {
|
|
1192
|
+
if (typeof value === 'string')
|
|
1193
|
+
return value.length > 0 ? value : undefined;
|
|
1194
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1195
|
+
return String(value);
|
|
1196
|
+
if (admitBoolean && typeof value === 'boolean')
|
|
1197
|
+
return String(value);
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* The value at a reported fact's `responsePath` inside a vendor's JSON answer (#3397): a token answer, the claims of
|
|
1202
|
+
* its ID token, or a userinfo answer. Own properties only (the answer is a vendor's JSON, and `constructor` is not a
|
|
1203
|
+
* fact). A FOUND value is a non-empty string or a finite number's decimal text.
|
|
1204
|
+
*
|
|
1205
|
+
* A selection step keeps the ONE array element whose field reads as the stated value (a string as itself, a number as
|
|
1206
|
+
* its decimal text, a boolean as `true` / `false`, so DocuSign's `is_default: true` matches `[is_default=true]` whether
|
|
1207
|
+
* it is sent as a boolean or a string). Zero or several matches is {@link ProviderReportedFactReadOutcome.SELECTION_NOT_SINGLE}:
|
|
1208
|
+
* two facts selected with the same condition (`accounts[is_default=true].account_id` and `….base_uri`) are therefore
|
|
1209
|
+
* read from the same element or not at all. Anything else that does not lead to a scalar (a missing key, a property
|
|
1210
|
+
* that is not an array where a selection is written, an unparsable path) is ABSENT.
|
|
1211
|
+
*/
|
|
1212
|
+
export function readProviderReportedFactValue(payload, path) {
|
|
1213
|
+
const segments = parseProviderReportedFactPath(path);
|
|
1214
|
+
if (segments === undefined)
|
|
1215
|
+
return { outcome: ProviderReportedFactReadOutcome.ABSENT };
|
|
1216
|
+
let current = payload;
|
|
1217
|
+
for (const segment of segments) {
|
|
1218
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current) || !Object.hasOwn(current, segment.key)) {
|
|
1219
|
+
return { outcome: ProviderReportedFactReadOutcome.ABSENT };
|
|
1220
|
+
}
|
|
1221
|
+
current = current[segment.key];
|
|
1222
|
+
if (segment.select === undefined)
|
|
1223
|
+
continue;
|
|
1224
|
+
if (!Array.isArray(current))
|
|
1225
|
+
return { outcome: ProviderReportedFactReadOutcome.ABSENT };
|
|
1226
|
+
const { field, equals } = segment.select;
|
|
1227
|
+
const matches = current.filter((element) => element !== null && typeof element === 'object' && !Array.isArray(element)
|
|
1228
|
+
&& Object.hasOwn(element, field) && providerReportedScalarText(element[field], true) === equals);
|
|
1229
|
+
if (matches.length !== 1) {
|
|
1230
|
+
return { outcome: ProviderReportedFactReadOutcome.SELECTION_NOT_SINGLE, segment: `${segment.key}[${field}=${equals}]`, matched: matches.length };
|
|
1231
|
+
}
|
|
1232
|
+
current = matches[0];
|
|
1233
|
+
}
|
|
1234
|
+
const value = providerReportedScalarText(current, false);
|
|
1235
|
+
return value === undefined ? { outcome: ProviderReportedFactReadOutcome.ABSENT } : { outcome: ProviderReportedFactReadOutcome.FOUND, value };
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* The query parameters an authorization redirect carries for the OAuth protocol itself, never as an account fact: a
|
|
1239
|
+
* redirect-sourced fact named like one would read the code, the state or the error as the account.
|
|
1240
|
+
* spec-cite: RFC 6749 §4.1.2 (`code`, `state`) and §4.1.2.1 (`error`, `error_description`, `error_uri`); RFC 9207 §2
|
|
1241
|
+
* (`iss`).
|
|
1242
|
+
* semantic-literal-boundary: the OAuth protocol's own redirect parameter names, compared with a module's declaration.
|
|
1243
|
+
*/
|
|
1244
|
+
export const PROVIDER_OAUTH2_REDIRECT_PROTOCOL_PARAMETERS = ['code', 'state', 'error', 'error_description', 'error_uri', 'iss'];
|
|
643
1245
|
/** What a {@link ProviderOAuth2IdTokenRequirement} asks of the sign-in's ID token claims. */
|
|
644
1246
|
export var ProviderOAuth2IdTokenRequirementKind;
|
|
645
1247
|
(function (ProviderOAuth2IdTokenRequirementKind) {
|
|
@@ -753,6 +1355,118 @@ export function describeProviderOAuth2IdTokenRequirementDefects(oauth2) {
|
|
|
753
1355
|
}
|
|
754
1356
|
return defects;
|
|
755
1357
|
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Whether a vendor-reported value may stand as one path segment under its fact's declared
|
|
1360
|
+
* {@link ProviderOAuth2ReportedAccountFact.pathSegmentPattern} (#3397): the WHOLE value matches the pattern, and it is
|
|
1361
|
+
* not empty, not a dot-segment and holds no `/`, whatever the pattern admits. The one admission, applied where the value
|
|
1362
|
+
* is captured (the authorization) and where it is spent (every call). A pattern that does not compile admits nothing.
|
|
1363
|
+
*/
|
|
1364
|
+
export function providerReportedPathSegmentAdmits(pattern, value) {
|
|
1365
|
+
if (value.length === 0 || value === '.' || value === '..' || value.includes('/'))
|
|
1366
|
+
return false;
|
|
1367
|
+
let expression;
|
|
1368
|
+
try {
|
|
1369
|
+
expression = new RegExp(`^(?:${pattern})$`, 'u');
|
|
1370
|
+
}
|
|
1371
|
+
catch {
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
return expression.test(value);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* What makes the declared USES of a connection's vendor-reported facts unsafe or unusable (#3397), checked at boot with
|
|
1378
|
+
* the contract's other declarations:
|
|
1379
|
+
*
|
|
1380
|
+
* - a fact read from the authorization redirect is something the person who authorized could change, so it may not be
|
|
1381
|
+
* an `origin`, fill a base URL host, or route inbound deliveries, and its name must be one query parameter that is
|
|
1382
|
+
* not the OAuth protocol's own (`code`, `state`, …);
|
|
1383
|
+
* - a `pathSegmentPattern` must compile, admit no empty value, and not sit beside an `origin`;
|
|
1384
|
+
* - a `fillsBaseUrlHost` fact must name a `{placeholder}` of the base URL that a `DECLARED_VALUES` allowance governs,
|
|
1385
|
+
* must not also be an `origin` or something the person is asked at connect, and cannot share a contract with
|
|
1386
|
+
* `baseUrlConnectionFact`, which already decides where calls go;
|
|
1387
|
+
* - every other `responsePath` must parse ({@link parseProviderReportedFactPath});
|
|
1388
|
+
* - a fact read from the userinfo answer needs a `userInfoUrl`, a `userInfoUrl` needs a fact that reads it (a request
|
|
1389
|
+
* carrying the person's token that nothing uses is not made), and the userinfo endpoint may carry only the
|
|
1390
|
+
* host-forming placeholders the token endpoint carries, in the same position (it is the same authorization server).
|
|
1391
|
+
*/
|
|
1392
|
+
export function describeProviderOAuth2ReportedFactUseDefects(contract) {
|
|
1393
|
+
const defects = [];
|
|
1394
|
+
for (const [name, fact] of Object.entries(contract.oauth2?.reportedAccountFacts ?? {})) {
|
|
1395
|
+
const at = `reported fact '${name}'`;
|
|
1396
|
+
if (fact.source === ProviderOAuth2ReportedFactSource.AUTHORIZATION_REDIRECT_QUERY) {
|
|
1397
|
+
if (fact.responsePath.length === 0 || fact.responsePath.includes('.') || PROVIDER_OAUTH2_REDIRECT_PROTOCOL_PARAMETERS.includes(fact.responsePath)) {
|
|
1398
|
+
defects.push(`${at} reads the redirect query parameter '${fact.responsePath}', which is not one parameter name outside the OAuth protocol's own`);
|
|
1399
|
+
}
|
|
1400
|
+
if (fact.origin !== undefined || fact.fillsBaseUrlHost === true || fact.routesInboundDeliveries === true) {
|
|
1401
|
+
defects.push(`${at} comes from the authorization redirect, which the person authorizing could alter, so it may not decide an origin, a host or delivery routing`);
|
|
1402
|
+
}
|
|
1403
|
+
if (fact.responsePath.includes('['))
|
|
1404
|
+
defects.push(`${at} selects inside the redirect query parameter '${fact.responsePath}', which is one string`);
|
|
1405
|
+
}
|
|
1406
|
+
else if (parseProviderReportedFactPath(fact.responsePath) === undefined) {
|
|
1407
|
+
defects.push(`${at} reads the responsePath ${JSON.stringify(fact.responsePath)}, which is not a dot path of names, each optionally selecting one element as name[field=value]`);
|
|
1408
|
+
}
|
|
1409
|
+
if (fact.source === ProviderOAuth2ReportedFactSource.USERINFO_RESPONSE && contract.oauth2?.userInfoUrl === undefined) {
|
|
1410
|
+
defects.push(`${at} reads the userinfo answer, and the OAuth2 contract declares no userInfoUrl`);
|
|
1411
|
+
}
|
|
1412
|
+
if (fact.pathSegmentPattern !== undefined) {
|
|
1413
|
+
let compiles = true;
|
|
1414
|
+
try {
|
|
1415
|
+
new RegExp(`^(?:${fact.pathSegmentPattern})$`, 'u');
|
|
1416
|
+
}
|
|
1417
|
+
catch {
|
|
1418
|
+
compiles = false;
|
|
1419
|
+
}
|
|
1420
|
+
if (!compiles)
|
|
1421
|
+
defects.push(`${at} declares the pathSegmentPattern ${JSON.stringify(fact.pathSegmentPattern)}, which does not compile`);
|
|
1422
|
+
else if (new RegExp(`^(?:${fact.pathSegmentPattern})$`, 'u').test(''))
|
|
1423
|
+
defects.push(`${at} declares a pathSegmentPattern that admits an empty value`);
|
|
1424
|
+
if (fact.origin !== undefined)
|
|
1425
|
+
defects.push(`${at} is both an origin and a path value; a fact addresses the account one way`);
|
|
1426
|
+
}
|
|
1427
|
+
if (fact.fillsBaseUrlHost === true) {
|
|
1428
|
+
if (fact.origin !== undefined)
|
|
1429
|
+
defects.push(`${at} is both an origin and a base URL host; a fact addresses the account one way`);
|
|
1430
|
+
if (contract.baseUrlConnectionFact !== undefined)
|
|
1431
|
+
defects.push(`${at} fills a base URL host beside baseUrlConnectionFact, and only one may decide where a call goes`);
|
|
1432
|
+
if (contract.baseUrl === undefined || !contract.baseUrl.includes(`{${name}}`))
|
|
1433
|
+
defects.push(`${at} fills a base URL host, and the base URL carries no '{${name}}' placeholder`);
|
|
1434
|
+
const allowance = contract.baseUrlHostAllowances !== undefined && Object.hasOwn(contract.baseUrlHostAllowances, name) ? contract.baseUrlHostAllowances[name] : undefined;
|
|
1435
|
+
if (allowance?.kind !== ProviderOAuth2EndpointHostAllowanceKind.DECLARED_VALUES) {
|
|
1436
|
+
defects.push(`${at} fills a base URL host that no DECLARED_VALUES baseUrlHostAllowances entry governs`);
|
|
1437
|
+
}
|
|
1438
|
+
if ((contract.oauth2?.connectionParameters ?? []).includes(name))
|
|
1439
|
+
defects.push(`${at} fills a base URL host the person is also asked for at connect`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
const userInfoUrl = contract.oauth2?.userInfoUrl;
|
|
1443
|
+
if (userInfoUrl !== undefined) {
|
|
1444
|
+
const facts = Object.values(contract.oauth2?.reportedAccountFacts ?? {});
|
|
1445
|
+
if (!facts.some((fact) => fact.source === ProviderOAuth2ReportedFactSource.USERINFO_RESPONSE)) {
|
|
1446
|
+
defects.push('declares a userInfoUrl that no reported fact reads, so the person\'s token would be sent there for nothing');
|
|
1447
|
+
}
|
|
1448
|
+
const templateDefects = findProviderOAuth2EndpointTemplateDefects(userInfoUrl);
|
|
1449
|
+
if (templateDefects.length > 0 || !userInfoUrl.toLowerCase().startsWith('https://')) {
|
|
1450
|
+
defects.push(`declares the userInfoUrl '${userInfoUrl}', which is not an https endpoint template (${templateDefects.join(', ') || 'not https'})`);
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
const tokenPositions = new Map();
|
|
1454
|
+
if (contract.oauth2?.tokenUrl !== undefined && findProviderOAuth2EndpointTemplateDefects(contract.oauth2.tokenUrl).length === 0) {
|
|
1455
|
+
for (const occurrence of classifyProviderOAuth2EndpointPlaceholders(contract.oauth2.tokenUrl))
|
|
1456
|
+
tokenPositions.set(occurrence.name, occurrence.position);
|
|
1457
|
+
}
|
|
1458
|
+
for (const occurrence of classifyProviderOAuth2EndpointPlaceholders(userInfoUrl)) {
|
|
1459
|
+
if (occurrence.position === ProviderOAuth2EndpointPlaceholderPosition.PATH_OR_QUERY) {
|
|
1460
|
+
defects.push(`the userInfoUrl carries the path placeholder '{${occurrence.name}}'; it is one fixed endpoint of the authorization server`);
|
|
1461
|
+
}
|
|
1462
|
+
else if (tokenPositions.get(occurrence.name) !== occurrence.position) {
|
|
1463
|
+
defects.push(`the userInfoUrl's host placeholder '{${occurrence.name}}' is not the token endpoint's own, in the same position; both belong to one authorization server`);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return defects;
|
|
1469
|
+
}
|
|
756
1470
|
/** How a placeholder's value is compared with a declared one. */
|
|
757
1471
|
export var ProviderOAuth2ValueComparison;
|
|
758
1472
|
(function (ProviderOAuth2ValueComparison) {
|
|
@@ -993,6 +1707,91 @@ export var ProviderOAuth2EndpointTemplateDefect;
|
|
|
993
1707
|
* hyphen; 63 octets per label).
|
|
994
1708
|
*/
|
|
995
1709
|
export const PROVIDER_OAUTH2_DNS_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
1710
|
+
/**
|
|
1711
|
+
* Whether `host` is `<labels><suffix>`: it ends with the dotted `suffix`, what precedes the suffix is a DNS name (never
|
|
1712
|
+
* empty, so the bare suffix is not admitted), and that DNS name carries at most `maxLabels` labels (#3640).
|
|
1713
|
+
*
|
|
1714
|
+
* THE one suffix test. Every place a declared host suffix admits a host — an answer filled into an endpoint or a base URL,
|
|
1715
|
+
* an audience selecting a request host, the token-endpoint host description, a declined value a suffix would still admit —
|
|
1716
|
+
* asks this function, so a label bound declared once holds at all of them. `maxLabels` absent means unbounded, which is
|
|
1717
|
+
* what every suffix meant before the bound existed. A bound that is not a positive integer admits nothing (fail closed):
|
|
1718
|
+
* the declaration checks refuse it where a person is reading a diff, and this keeps the runtime closed regardless.
|
|
1719
|
+
*
|
|
1720
|
+
* Case-insensitive; the caller need not lower-case.
|
|
1721
|
+
*/
|
|
1722
|
+
export function providerHostIsUnderSuffix(host, suffix, maxLabels) {
|
|
1723
|
+
const candidate = host.toLowerCase();
|
|
1724
|
+
const lowered = suffix.toLowerCase();
|
|
1725
|
+
if (!lowered.startsWith('.') || !candidate.endsWith(lowered) || candidate.length === lowered.length)
|
|
1726
|
+
return false;
|
|
1727
|
+
const labels = candidate.slice(0, candidate.length - lowered.length);
|
|
1728
|
+
if (!PROVIDER_OAUTH2_DNS_NAME_PATTERN.test(labels))
|
|
1729
|
+
return false;
|
|
1730
|
+
return maxLabels === undefined || labels.split('.').length <= maxLabels;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Whether some host is admitted under BOTH suffixes (#3640). Two suffixes can only share a host when one ends with the
|
|
1734
|
+
* other (`.a.b` and `.b`): a host under the longer one carries the extra labels (`.a`) before the shorter one, so the
|
|
1735
|
+
* shorter one's bound must leave room for one label of its own plus those. Equal suffixes always share a host.
|
|
1736
|
+
*/
|
|
1737
|
+
export function providerHostSuffixesOverlap(left, right) {
|
|
1738
|
+
const leftSuffix = left.suffix.toLowerCase();
|
|
1739
|
+
const rightSuffix = right.suffix.toLowerCase();
|
|
1740
|
+
const [longer, shorter] = leftSuffix.length >= rightSuffix.length ? [left, right] : [right, left];
|
|
1741
|
+
const longerSuffix = longer.suffix.toLowerCase();
|
|
1742
|
+
const shorterSuffix = shorter.suffix.toLowerCase();
|
|
1743
|
+
if (!longerSuffix.endsWith(shorterSuffix))
|
|
1744
|
+
return false;
|
|
1745
|
+
// The probe host: one label under the longer suffix, which is the fewest labels any host under it carries.
|
|
1746
|
+
return providerHostIsUnderSuffix(`a${longerSuffix}`, longerSuffix, longer.maxLabels) && providerHostIsUnderSuffix(`a${longerSuffix}`, shorterSuffix, shorter.maxLabels);
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* The bare answer for a placeholder that OPENS a host, when the person typed the whole host (#3640).
|
|
1750
|
+
*
|
|
1751
|
+
* `https://{shop}.myshopify.com` asks for the store's handle, and the person connecting sees `acme.myshopify.com` in the
|
|
1752
|
+
* vendor's own screens. Filled verbatim, that answer dials `acme.myshopify.com.myshopify.com`: a host the suffix admits
|
|
1753
|
+
* (it ends in `.myshopify.com`) that does not exist, so every request fails on DNS naming a host rather than the answer.
|
|
1754
|
+
* The literal text the template writes after the placeholder (`literalHostSuffix`) is therefore stripped ONCE from an
|
|
1755
|
+
* answer that ends with it: `acme.myshopify.com` → `acme`. Nothing else is rewritten; the result is then admitted like
|
|
1756
|
+
* any other answer, so an answer with too many labels left (`acme.evil.myshopify.com` → `acme.evil` under a one-label
|
|
1757
|
+
* bound) is still refused.
|
|
1758
|
+
*
|
|
1759
|
+
* Generic rather than per vendor, because it is a property of the TEMPLATE: every provider whose host opens with a
|
|
1760
|
+
* placeholder (Shopify, Zendesk) asks for the part before a literal the vendor shows people with it attached. Done where
|
|
1761
|
+
* the template is filled, not in a connect prompt, so the OAuth start, the stored-answer re-admission and every
|
|
1762
|
+
* operation's base URL read the same answer the same way, whichever screen or flag supplied it. A host that literally
|
|
1763
|
+
* repeated its suffix is never a real vendor host, so nothing a person could mean is lost.
|
|
1764
|
+
*/
|
|
1765
|
+
export function stripProviderHostAnswerTemplateSuffix(value, literalHostSuffix) {
|
|
1766
|
+
if (literalHostSuffix === undefined || literalHostSuffix === '')
|
|
1767
|
+
return value;
|
|
1768
|
+
return value.length > literalHostSuffix.length && value.toLowerCase().endsWith(literalHostSuffix.toLowerCase())
|
|
1769
|
+
? value.slice(0, value.length - literalHostSuffix.length)
|
|
1770
|
+
: value;
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* The words a refusal uses for the answer a bounded suffix expects (#3640): `a single DNS label before .myshopify.com
|
|
1774
|
+
* (the 'acme' of acme.myshopify.com)`. Shared so every refusal names the same form.
|
|
1775
|
+
*/
|
|
1776
|
+
export function describeProviderHostSuffixExpectedForm(suffix, maxLabels, answerExcludesSuffix) {
|
|
1777
|
+
const count = maxLabels === undefined ? 'DNS labels' : maxLabels === 1 ? 'a single DNS label' : `at most ${maxLabels} DNS labels`;
|
|
1778
|
+
const example = answerExcludesSuffix ? `the 'acme' of acme${suffix}` : `acme${suffix}`;
|
|
1779
|
+
return `${count} before ${suffix} (${example})`;
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Why a declared label bound on host suffixes cannot be honoured (#3640): it is not a positive integer, or there is no
|
|
1783
|
+
* suffix for it to bound. Shared by the endpoint allowances and the audiences, so both refuse the same shapes.
|
|
1784
|
+
*/
|
|
1785
|
+
export function describeProviderHostSuffixMaxLabelsDefects(maxLabels, hostSuffixes) {
|
|
1786
|
+
if (maxLabels === undefined)
|
|
1787
|
+
return [];
|
|
1788
|
+
const defects = [];
|
|
1789
|
+
if (!Number.isInteger(maxLabels) || maxLabels < 1)
|
|
1790
|
+
defects.push(`hostSuffixMaxLabels ${String(maxLabels)} is not a positive integer, which admits no host`);
|
|
1791
|
+
if ((hostSuffixes ?? []).length === 0)
|
|
1792
|
+
defects.push('hostSuffixMaxLabels is declared with no hostSuffixes to bound');
|
|
1793
|
+
return defects;
|
|
1794
|
+
}
|
|
996
1795
|
/**
|
|
997
1796
|
* Why a DECLARED host allowance cannot be applied where its placeholder sits — the position rules the
|
|
998
1797
|
* transposer enforces at GENERATION time, so a broken declaration refuses the batch instead of being
|
|
@@ -1076,10 +1875,13 @@ export function describeProviderOAuth2EndpointHostAllowanceDefects(placeholder,
|
|
|
1076
1875
|
defects.push(`host suffix '${suffix}' must begin with '.' and name at least two DNS labels (a bare '.com' admits every host under a TLD)`);
|
|
1077
1876
|
}
|
|
1078
1877
|
}
|
|
1878
|
+
defects.push(...describeProviderHostSuffixMaxLabelsDefects(allowance.hostSuffixMaxLabels, allowance.hostSuffixes));
|
|
1079
1879
|
defects.push(...describeProviderOAuth2UnsupportedValueDefects(allowance.unsupportedValues, allowance.values, ProviderOAuth2ValueComparison.CASE_INSENSITIVE));
|
|
1080
1880
|
for (const declined of Object.keys(allowance.unsupportedValues ?? {})) {
|
|
1081
1881
|
// A declined value a declared SUFFIX would still admit is admitted, whatever the reason says.
|
|
1082
|
-
|
|
1882
|
+
// ORIGIN values carry the scheme; the suffix is matched against the host they name.
|
|
1883
|
+
const declinedHost = declined.toLowerCase().replace(/^https:\/\//, '');
|
|
1884
|
+
const coveringSuffix = (allowance.hostSuffixes ?? []).find((suffix) => providerHostIsUnderSuffix(declinedHost, suffix, allowance.hostSuffixMaxLabels));
|
|
1083
1885
|
if (coveringSuffix !== undefined)
|
|
1084
1886
|
defects.push(`'${declined}' is declared unsupported but the host suffix '${coveringSuffix}' admits it`);
|
|
1085
1887
|
}
|