@assinafy/sdk 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -1
- package/README.en.md +1373 -0
- package/README.md +764 -564
- package/SECURITY.md +13 -0
- package/dist/index.d.mts +778 -7
- package/dist/index.d.ts +778 -7
- package/dist/index.js +844 -34
- package/dist/index.mjs +839 -32
- package/docs/API_COVERAGE.md +28 -4
- package/docs/COMPATIBILITY.md +63 -0
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -32,6 +32,29 @@ var AssinafyError = class extends Error {
|
|
|
32
32
|
var ApiError = class _ApiError extends AssinafyError {
|
|
33
33
|
statusCode;
|
|
34
34
|
responseData;
|
|
35
|
+
/**
|
|
36
|
+
* Parsed `WWW-Authenticate` challenge, when the response carried one.
|
|
37
|
+
*
|
|
38
|
+
* A `403` whose challenge is `{ error: 'insufficient_scope', scope: '…' }`
|
|
39
|
+
* means the OAuth token is valid but was never granted that permission:
|
|
40
|
+
* send the user through the authorization flow again asking for the scope
|
|
41
|
+
* named in `scope`. A `403` without a challenge has a different cause —
|
|
42
|
+
* another workspace, the user's role, or a surface OAuth tokens never
|
|
43
|
+
* reach — and reconnecting will not fix it.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```ts
|
|
47
|
+
* try {
|
|
48
|
+
* await connected.documents.upload({ filePath: './contract.pdf' });
|
|
49
|
+
* } catch (error) {
|
|
50
|
+
* if (error instanceof ApiError && error.challenge?.error === 'insufficient_scope') {
|
|
51
|
+
* return reconnect(error.challenge.scope); // 'documents:write'
|
|
52
|
+
* }
|
|
53
|
+
* throw error;
|
|
54
|
+
* }
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
challenge;
|
|
35
58
|
/**
|
|
36
59
|
* Create an error representing a non-success API response.
|
|
37
60
|
*
|
|
@@ -77,6 +100,55 @@ var ApiError = class _ApiError extends AssinafyError {
|
|
|
77
100
|
return new _ApiError(message, statusCode, responseData);
|
|
78
101
|
}
|
|
79
102
|
};
|
|
103
|
+
var OAuthError = class _OAuthError extends ApiError {
|
|
104
|
+
/** RFC 6749 error code, e.g. `invalid_grant`. */
|
|
105
|
+
error;
|
|
106
|
+
/** The server's human-readable explanation, when it sent one. */
|
|
107
|
+
errorDescription;
|
|
108
|
+
/**
|
|
109
|
+
* Create an OAuth protocol error.
|
|
110
|
+
*
|
|
111
|
+
* @param error - RFC 6749 error code.
|
|
112
|
+
* @param errorDescription - Server-provided explanation, or `null`.
|
|
113
|
+
* @param statusCode - HTTP status that carried it. Authorization responses
|
|
114
|
+
* arrive as redirect query parameters rather than an HTTP response, so
|
|
115
|
+
* {@link OAuthResource.readAuthorizationCallback} reports them as `400`.
|
|
116
|
+
* @param responseData - The raw error object.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* throw new OAuthError('invalid_grant', 'Authorization code expired.', 400);
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
constructor(error, errorDescription = null, statusCode = 400, responseData = null) {
|
|
124
|
+
super(errorDescription ? `${error}: ${errorDescription}` : error, statusCode, responseData);
|
|
125
|
+
this.name = "OAuthError";
|
|
126
|
+
this.error = error;
|
|
127
|
+
this.errorDescription = errorDescription;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Upgrade an {@link ApiError} to an {@link OAuthError} when its body is an
|
|
131
|
+
* RFC 6749 error object; otherwise return the value untouched.
|
|
132
|
+
*
|
|
133
|
+
* @param error - Any thrown value.
|
|
134
|
+
* @returns An `OAuthError` when the body carries a non-empty string
|
|
135
|
+
* `error`, else the original value.
|
|
136
|
+
*/
|
|
137
|
+
static upgrade(error) {
|
|
138
|
+
if (!(error instanceof ApiError) || error instanceof _OAuthError) return error;
|
|
139
|
+
const body = error.responseData;
|
|
140
|
+
if (body === null || typeof body !== "object") return error;
|
|
141
|
+
const code = body["error"];
|
|
142
|
+
if (typeof code !== "string" || code.length === 0) return error;
|
|
143
|
+
const description = body["error_description"];
|
|
144
|
+
return new _OAuthError(
|
|
145
|
+
code,
|
|
146
|
+
typeof description === "string" && description.length > 0 ? description : null,
|
|
147
|
+
error.statusCode,
|
|
148
|
+
body
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
80
152
|
var ValidationError = class extends AssinafyError {
|
|
81
153
|
errors;
|
|
82
154
|
/**
|
|
@@ -116,6 +188,43 @@ var NetworkError = class extends AssinafyError {
|
|
|
116
188
|
|
|
117
189
|
// src/utils.ts
|
|
118
190
|
import axios from "axios";
|
|
191
|
+
|
|
192
|
+
// src/support/headers.ts
|
|
193
|
+
function readHeader(headers, name) {
|
|
194
|
+
if (!headers) return void 0;
|
|
195
|
+
const lower = name.toLowerCase();
|
|
196
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
197
|
+
if (key.toLowerCase() === lower && value != null) {
|
|
198
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
199
|
+
if (typeof first === "string" || typeof first === "number" || typeof first === "boolean") {
|
|
200
|
+
return String(first);
|
|
201
|
+
}
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return void 0;
|
|
206
|
+
}
|
|
207
|
+
var CHALLENGE_PARAM = /([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|([^\s,]+))/gu;
|
|
208
|
+
function parseWwwAuthenticate(value) {
|
|
209
|
+
if (typeof value !== "string") return void 0;
|
|
210
|
+
const trimmed = value.trim();
|
|
211
|
+
const scheme = /^[A-Za-z0-9_-]+/u.exec(trimmed)?.[0];
|
|
212
|
+
if (!scheme) return void 0;
|
|
213
|
+
const challenge = { scheme };
|
|
214
|
+
CHALLENGE_PARAM.lastIndex = 0;
|
|
215
|
+
for (const match of trimmed.slice(scheme.length).matchAll(CHALLENGE_PARAM)) {
|
|
216
|
+
const key = match[1]?.toLowerCase();
|
|
217
|
+
const paramValue = match[2] ?? match[3];
|
|
218
|
+
if (paramValue === void 0) continue;
|
|
219
|
+
if (key === "error") challenge.error = paramValue;
|
|
220
|
+
else if (key === "error_description") challenge.error_description = paramValue;
|
|
221
|
+
else if (key === "scope") challenge.scope = paramValue;
|
|
222
|
+
else if (key === "resource_metadata") challenge.resource_metadata = paramValue;
|
|
223
|
+
}
|
|
224
|
+
return challenge;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/utils.ts
|
|
119
228
|
var SAFE_LOG_NUMBER_FIELDS = /* @__PURE__ */ new Set([
|
|
120
229
|
"attempt",
|
|
121
230
|
"attempts",
|
|
@@ -159,7 +268,12 @@ function toSdkError(error, fallbackMessage) {
|
|
|
159
268
|
const status = error.response?.status;
|
|
160
269
|
if (status) {
|
|
161
270
|
const body = decodeBinaryErrorBody(error.response?.data ?? null);
|
|
162
|
-
|
|
271
|
+
const apiError = ApiError.fromResponse(status, body ?? null);
|
|
272
|
+
const challenge = parseWwwAuthenticate(
|
|
273
|
+
readHeader(error.response?.headers, "www-authenticate")
|
|
274
|
+
);
|
|
275
|
+
if (challenge) apiError.challenge = challenge;
|
|
276
|
+
return apiError;
|
|
163
277
|
}
|
|
164
278
|
const cause = sanitiseNetworkCause(error);
|
|
165
279
|
return new NetworkError(`${fallbackMessage}: ${cause.message}`, { cause });
|
|
@@ -309,22 +423,6 @@ function cleanListParams(params) {
|
|
|
309
423
|
return out;
|
|
310
424
|
}
|
|
311
425
|
|
|
312
|
-
// src/support/headers.ts
|
|
313
|
-
function readHeader(headers, name) {
|
|
314
|
-
if (!headers) return void 0;
|
|
315
|
-
const lower = name.toLowerCase();
|
|
316
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
317
|
-
if (key.toLowerCase() === lower && value != null) {
|
|
318
|
-
const first = Array.isArray(value) ? value[0] : value;
|
|
319
|
-
if (typeof first === "string" || typeof first === "number" || typeof first === "boolean") {
|
|
320
|
-
return String(first);
|
|
321
|
-
}
|
|
322
|
-
return void 0;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
return void 0;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
426
|
// src/support/retry.ts
|
|
329
427
|
function retryDelayFromHeaders(headers) {
|
|
330
428
|
const retryAfter = readHeader(headers, "retry-after");
|
|
@@ -476,7 +574,7 @@ import axios2 from "axios";
|
|
|
476
574
|
// package.json
|
|
477
575
|
var package_default = {
|
|
478
576
|
name: "@assinafy/sdk",
|
|
479
|
-
version: "2.
|
|
577
|
+
version: "2.4.0",
|
|
480
578
|
packageManager: "bun@1.4.0",
|
|
481
579
|
description: "TypeScript SDK for Assinafy API - Digital signature platform",
|
|
482
580
|
type: "commonjs",
|
|
@@ -563,6 +661,7 @@ var package_default = {
|
|
|
563
661
|
"dist",
|
|
564
662
|
"docs",
|
|
565
663
|
"README.md",
|
|
664
|
+
"README.en.md",
|
|
566
665
|
"CHANGELOG.md",
|
|
567
666
|
"SECURITY.md",
|
|
568
667
|
"LICENSE"
|
|
@@ -814,14 +913,34 @@ function toInt(value) {
|
|
|
814
913
|
var ASSIGNMENT_METHODS = /* @__PURE__ */ new Set(["virtual", "collect"]);
|
|
815
914
|
var VERIFICATION_METHODS = /* @__PURE__ */ new Set(["Email", "Whatsapp", "DigitalCertificate"]);
|
|
816
915
|
var NOTIFICATION_METHODS = /* @__PURE__ */ new Set(["Email", "Whatsapp"]);
|
|
916
|
+
var ALLOWED_NOTIFICATION_METHODS = {
|
|
917
|
+
Email: /* @__PURE__ */ new Set(["Email"]),
|
|
918
|
+
Whatsapp: /* @__PURE__ */ new Set(["Whatsapp"]),
|
|
919
|
+
DigitalCertificate: NOTIFICATION_METHODS
|
|
920
|
+
};
|
|
817
921
|
function validateAssignmentSignerOptions(signer, label = "signer") {
|
|
818
922
|
if (signer.verification_method !== void 0 && (typeof signer.verification_method !== "string" || !VERIFICATION_METHODS.has(signer.verification_method))) {
|
|
819
923
|
throw new ValidationError(`${label} has an invalid verification_method`);
|
|
820
924
|
}
|
|
821
|
-
if (signer.notification_methods !== void 0
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
925
|
+
if (signer.notification_methods !== void 0) {
|
|
926
|
+
const methods = signer.notification_methods;
|
|
927
|
+
if (!Array.isArray(methods) || methods.some(
|
|
928
|
+
(method) => typeof method !== "string" || !NOTIFICATION_METHODS.has(method)
|
|
929
|
+
)) {
|
|
930
|
+
throw new ValidationError(`${label} has invalid notification_methods`);
|
|
931
|
+
}
|
|
932
|
+
if (methods.length !== 1) {
|
|
933
|
+
throw new ValidationError(`${label} allows exactly one notification method`);
|
|
934
|
+
}
|
|
935
|
+
const verification = signer.verification_method;
|
|
936
|
+
if (typeof verification === "string") {
|
|
937
|
+
const allowed = ALLOWED_NOTIFICATION_METHODS[verification];
|
|
938
|
+
if (allowed && !allowed.has(methods[0])) {
|
|
939
|
+
throw new ValidationError(
|
|
940
|
+
`${label} cannot pair ${verification} verification with ${String(methods[0])} notification`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
825
944
|
}
|
|
826
945
|
if (signer.step !== void 0 && (typeof signer.step !== "number" || !Number.isSafeInteger(signer.step) || signer.step < 1)) {
|
|
827
946
|
throw new ValidationError(`${label} step must be a positive safe integer`);
|
|
@@ -892,8 +1011,8 @@ function buildAssignmentEstimatePayload(payload) {
|
|
|
892
1011
|
throw new ValidationError("method must be virtual or collect");
|
|
893
1012
|
}
|
|
894
1013
|
const signers = Array.isArray(payload.signers) ? payload.signers : [];
|
|
895
|
-
if (
|
|
896
|
-
throw new ValidationError("At least one signer is required for a
|
|
1014
|
+
if (signers.length === 0) {
|
|
1015
|
+
throw new ValidationError("At least one signer is required for a cost estimate");
|
|
897
1016
|
}
|
|
898
1017
|
const entries = payload.entries === void 0 ? void 0 : normaliseAssignmentEntries(payload.entries);
|
|
899
1018
|
if (method === "collect" && (!entries || entries.length === 0)) {
|
|
@@ -901,7 +1020,7 @@ function buildAssignmentEstimatePayload(payload) {
|
|
|
901
1020
|
}
|
|
902
1021
|
return cleanParams({
|
|
903
1022
|
method,
|
|
904
|
-
signers: signers.
|
|
1023
|
+
signers: signers.map((signer) => normaliseEstimateSigner(signer)),
|
|
905
1024
|
entries
|
|
906
1025
|
});
|
|
907
1026
|
}
|
|
@@ -1282,7 +1401,8 @@ var AssignmentResource = class extends BaseResource {
|
|
|
1282
1401
|
* }
|
|
1283
1402
|
* ```
|
|
1284
1403
|
* @throws {ValidationError} If `documentId` is missing, a `virtual` request
|
|
1285
|
-
* has no signer entry, or a `collect` request has no
|
|
1404
|
+
* has no signer entry, or a `collect` request has no signer entry or no
|
|
1405
|
+
* field-placement entry.
|
|
1286
1406
|
* @throws {ApiError} If the API rejects the request.
|
|
1287
1407
|
*
|
|
1288
1408
|
* @example
|
|
@@ -2615,9 +2735,6 @@ function normaliseTemplateSigners(signers) {
|
|
|
2615
2735
|
throw new ValidationError(`Template signer ${index + 1} requires id`);
|
|
2616
2736
|
}
|
|
2617
2737
|
validateAssignmentSignerOptions(signer, `Template signer ${index + 1}`);
|
|
2618
|
-
if (signer.notification_methods !== void 0 && signer.notification_methods.length > 1) {
|
|
2619
|
-
throw new ValidationError(`Template signer ${index + 1} allows one notification method`);
|
|
2620
|
-
}
|
|
2621
2738
|
const projected = { role_id: signer.role_id, id: signer.id };
|
|
2622
2739
|
if (signer.verification_method !== void 0) {
|
|
2623
2740
|
projected.verification_method = signer.verification_method;
|
|
@@ -4780,6 +4897,686 @@ var AuthenticationResource = class extends BaseResource {
|
|
|
4780
4897
|
}
|
|
4781
4898
|
};
|
|
4782
4899
|
|
|
4900
|
+
// src/resources/oauth.ts
|
|
4901
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
4902
|
+
var PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
|
|
4903
|
+
var AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
|
|
4904
|
+
var CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/u;
|
|
4905
|
+
var OAuthResource = class extends BaseResource {
|
|
4906
|
+
publicHttp;
|
|
4907
|
+
constructor(http, defaultAccountId, logger, publicHttp) {
|
|
4908
|
+
super(http, defaultAccountId, logger);
|
|
4909
|
+
this.publicHttp = withoutCredentials(publicHttp ?? http);
|
|
4910
|
+
}
|
|
4911
|
+
/**
|
|
4912
|
+
* Read this API's protected-resource metadata
|
|
4913
|
+
* (`GET /.well-known/oauth-protected-resource`).
|
|
4914
|
+
*
|
|
4915
|
+
* Served at the API host root — not under `/v1` — and bare, without the
|
|
4916
|
+
* `{ status, message, data }` envelope, as RFC 8615 requires. Use it to
|
|
4917
|
+
* discover which authorization server may issue tokens for this API and
|
|
4918
|
+
* which scopes it accepts.
|
|
4919
|
+
*
|
|
4920
|
+
* Request body: none. Authentication: none.
|
|
4921
|
+
*
|
|
4922
|
+
* @returns The metadata document:
|
|
4923
|
+
* ```jsonc
|
|
4924
|
+
* {
|
|
4925
|
+
* "resource": "https://api.assinafy.com.br",
|
|
4926
|
+
* "authorization_servers": ["https://auth.assinafy.com.br"],
|
|
4927
|
+
* "scopes_supported": [
|
|
4928
|
+
* "documents:read", "documents:write",
|
|
4929
|
+
* "templates:read", "templates:write",
|
|
4930
|
+
* "account:read", "openid", "profile", "email"
|
|
4931
|
+
* ],
|
|
4932
|
+
* "bearer_methods_supported": ["header"]
|
|
4933
|
+
* }
|
|
4934
|
+
* ```
|
|
4935
|
+
* `offline_access` is deliberately absent: it is a request-time signal to
|
|
4936
|
+
* the authorization server, not a permission this API enforces.
|
|
4937
|
+
* @throws {ApiError} If the host does not publish the document.
|
|
4938
|
+
*
|
|
4939
|
+
* @example
|
|
4940
|
+
* ```ts
|
|
4941
|
+
* const metadata = await client.oauth.getProtectedResourceMetadata();
|
|
4942
|
+
* console.log(metadata.authorization_servers[0]);
|
|
4943
|
+
* ```
|
|
4944
|
+
*/
|
|
4945
|
+
async getProtectedResourceMetadata() {
|
|
4946
|
+
return this.call(
|
|
4947
|
+
"Failed to fetch OAuth protected-resource metadata",
|
|
4948
|
+
() => this.publicHttp.get(`${this.apiOrigin()}${PROTECTED_RESOURCE_PATH}`)
|
|
4949
|
+
);
|
|
4950
|
+
}
|
|
4951
|
+
/**
|
|
4952
|
+
* Read the authorization server's metadata
|
|
4953
|
+
* (`GET {issuer}/.well-known/oauth-authorization-server`, RFC 8414).
|
|
4954
|
+
*
|
|
4955
|
+
* Every endpoint URL an OAuth client needs comes from here, so nothing has
|
|
4956
|
+
* to be hardcoded. The document is served by the authorization server, a
|
|
4957
|
+
* different host from this API.
|
|
4958
|
+
*
|
|
4959
|
+
* @param issuer - Issuer to read. Defaults to the first entry of
|
|
4960
|
+
* {@link OAuthResource.getProtectedResourceMetadata}, which costs one extra
|
|
4961
|
+
* request — pass the issuer to skip it.
|
|
4962
|
+
* @returns The metadata document:
|
|
4963
|
+
* ```jsonc
|
|
4964
|
+
* {
|
|
4965
|
+
* "issuer": "https://auth.assinafy.com.br",
|
|
4966
|
+
* "authorization_endpoint": "https://auth.assinafy.com.br/oauth/authorize",
|
|
4967
|
+
* "token_endpoint": "https://api.assinafy.com.br/v1/oauth/token",
|
|
4968
|
+
* "revocation_endpoint": "https://api.assinafy.com.br/v1/oauth/revoke",
|
|
4969
|
+
* "userinfo_endpoint": "https://api.assinafy.com.br/v1/oauth/userinfo",
|
|
4970
|
+
* "jwks_uri": "https://auth.assinafy.com.br/.well-known/jwks.json",
|
|
4971
|
+
* "scopes_supported": ["documents:read", "documents:write", "templates:read",
|
|
4972
|
+
* "templates:write", "account:read", "openid",
|
|
4973
|
+
* "profile", "email", "offline_access"],
|
|
4974
|
+
* "response_types_supported": ["code"],
|
|
4975
|
+
* "grant_types_supported": ["authorization_code", "refresh_token"],
|
|
4976
|
+
* "code_challenge_methods_supported": ["S256"],
|
|
4977
|
+
* "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
|
|
4978
|
+
* "authorization_response_iss_parameter_supported": true,
|
|
4979
|
+
* "client_id_metadata_document_supported": true
|
|
4980
|
+
* }
|
|
4981
|
+
* ```
|
|
4982
|
+
* @throws {ValidationError} If `issuer` is not an absolute `https://` URL,
|
|
4983
|
+
* or the document's own `issuer` disagrees with where it was fetched from
|
|
4984
|
+
* (RFC 8414 §3.3 — a mismatch means the document is not authoritative).
|
|
4985
|
+
* @throws {ApiError} If the authorization server rejects the request.
|
|
4986
|
+
*
|
|
4987
|
+
* @example
|
|
4988
|
+
* ```ts
|
|
4989
|
+
* const as = await client.oauth.getAuthorizationServerMetadata();
|
|
4990
|
+
* console.log(as.authorization_endpoint);
|
|
4991
|
+
* ```
|
|
4992
|
+
*/
|
|
4993
|
+
async getAuthorizationServerMetadata(issuer) {
|
|
4994
|
+
const resolved = issuer ?? await this.defaultIssuer();
|
|
4995
|
+
const base = assertHttpsUrl(resolved, "issuer").replace(/\/+$/u, "");
|
|
4996
|
+
const metadata = await this.call(
|
|
4997
|
+
"Failed to fetch OAuth authorization-server metadata",
|
|
4998
|
+
() => this.publicHttp.get(`${base}${AUTHORIZATION_SERVER_PATH}`)
|
|
4999
|
+
);
|
|
5000
|
+
if (normaliseIssuer(metadata?.issuer) !== normaliseIssuer(base)) {
|
|
5001
|
+
throw new ValidationError(
|
|
5002
|
+
"Authorization-server metadata issuer does not match the requested issuer",
|
|
5003
|
+
{ expected: base, received: metadata?.issuer ?? null }
|
|
5004
|
+
);
|
|
5005
|
+
}
|
|
5006
|
+
return metadata;
|
|
5007
|
+
}
|
|
5008
|
+
/**
|
|
5009
|
+
* Mint a PKCE pair and a `state`, then build the consent URL to send the
|
|
5010
|
+
* user's browser to (`GET {authorization_endpoint}`).
|
|
5011
|
+
*
|
|
5012
|
+
* Call this once per connection attempt and keep the whole returned object
|
|
5013
|
+
* in the user's session: reusing a verifier or a `state` across attempts
|
|
5014
|
+
* defeats both PKCE and CSRF protection. Navigate the browser to `url` with
|
|
5015
|
+
* a full page load — an `fetch`/XHR cannot show a consent screen.
|
|
5016
|
+
*
|
|
5017
|
+
* PKCE is mandatory for confidential applications too, and Assinafy accepts
|
|
5018
|
+
* only the `S256` challenge method.
|
|
5019
|
+
*
|
|
5020
|
+
* @param options - Authorization-request options.
|
|
5021
|
+
* @param options.clientId - The application's `client_id`.
|
|
5022
|
+
* @param options.redirectUri - One of the application's registered redirect
|
|
5023
|
+
* URIs, matched character for character (`…/callback` and `…/callback/` are
|
|
5024
|
+
* different). Must be `https://` and carry no fragment.
|
|
5025
|
+
* @param options.scopes - Permissions to request, e.g.
|
|
5026
|
+
* `['documents:read', 'documents:write', 'offline_access']`. Ask for the
|
|
5027
|
+
* minimum: the user approves all of them or none. Add `offline_access` to
|
|
5028
|
+
* receive a refresh token and `openid` to receive an `id_token`.
|
|
5029
|
+
* @param options.authorizationEndpoint - Skip discovery by supplying the
|
|
5030
|
+
* endpoint yourself. Defaults to the discovered
|
|
5031
|
+
* `authorization_endpoint`.
|
|
5032
|
+
* @param options.issuer - Issuer to discover from, and the value the
|
|
5033
|
+
* callback's `iss` must equal. Defaults to the discovered issuer.
|
|
5034
|
+
* @param options.resource - RFC 8707 resource indicator. Defaults to this
|
|
5035
|
+
* API's origin; pass `null` to omit it. It must match the value sent to the
|
|
5036
|
+
* token endpoint, or the exchange fails with `invalid_target`.
|
|
5037
|
+
* @param options.state - Supply your own CSRF value instead of a generated
|
|
5038
|
+
* one. Must be unique per attempt.
|
|
5039
|
+
* @param options.codeVerifier - Supply your own RFC 7636 verifier (43–128
|
|
5040
|
+
* characters from `A-Z a-z 0-9 - . _ ~`) instead of a generated one.
|
|
5041
|
+
* @param options.nonce - OIDC nonce echoed in the `id_token`. Generated
|
|
5042
|
+
* automatically when `openid` is requested; pass a string to set it or
|
|
5043
|
+
* `null` to omit it.
|
|
5044
|
+
* @param options.prompt - Forwarded as the OIDC `prompt` parameter, e.g.
|
|
5045
|
+
* `'consent'` to force the approval screen again.
|
|
5046
|
+
* @returns The request to store and redirect with:
|
|
5047
|
+
* ```jsonc
|
|
5048
|
+
* {
|
|
5049
|
+
* "url": "https://auth.assinafy.com.br/oauth/authorize?response_type=code&client_id=…&redirect_uri=https%3A%2F%2Fmyapp.com%2Foauth%2Fcallback&scope=documents%3Aread+offline_access&state=8Xv…&code_challenge=E9M…&code_challenge_method=S256&resource=https%3A%2F%2Fapi.assinafy.com.br",
|
|
5050
|
+
* "state": "8Xv2rQ7mJt0aLpKcWn4dZg",
|
|
5051
|
+
* "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
|
5052
|
+
* "issuer": "https://auth.assinafy.com.br",
|
|
5053
|
+
* "nonce": "n-0S6_WzA2Mj"
|
|
5054
|
+
* }
|
|
5055
|
+
* ```
|
|
5056
|
+
* @throws {ValidationError} If `clientId` is empty, `redirectUri` is not an
|
|
5057
|
+
* absolute `https://` URL without a fragment, `scopes` is empty or contains
|
|
5058
|
+
* a value with whitespace, or a supplied `codeVerifier`/`state` is invalid.
|
|
5059
|
+
* @throws {ApiError} If discovery is needed and fails.
|
|
5060
|
+
*
|
|
5061
|
+
* @example
|
|
5062
|
+
* ```ts
|
|
5063
|
+
* const request = await client.oauth.createAuthorizationUrl({
|
|
5064
|
+
* clientId: process.env.ASSINAFY_CLIENT_ID!,
|
|
5065
|
+
* redirectUri: 'https://myapp.com/oauth/callback',
|
|
5066
|
+
* scopes: ['documents:read', 'documents:write', 'offline_access'],
|
|
5067
|
+
* });
|
|
5068
|
+
* session.oauth = request;
|
|
5069
|
+
* response.redirect(request.url);
|
|
5070
|
+
* ```
|
|
5071
|
+
*/
|
|
5072
|
+
async createAuthorizationUrl(options) {
|
|
5073
|
+
assertRecord(options, "authorization options");
|
|
5074
|
+
assertNonEmptyString(options.clientId, "clientId");
|
|
5075
|
+
assertRedirectUri(options.redirectUri);
|
|
5076
|
+
const scope = assertScopes(options.scopes);
|
|
5077
|
+
let endpoint = options.authorizationEndpoint;
|
|
5078
|
+
let issuer = options.issuer;
|
|
5079
|
+
if (endpoint === void 0 || issuer === void 0) {
|
|
5080
|
+
const metadata = await this.getAuthorizationServerMetadata(options.issuer);
|
|
5081
|
+
endpoint ??= metadata.authorization_endpoint;
|
|
5082
|
+
issuer ??= metadata.issuer;
|
|
5083
|
+
}
|
|
5084
|
+
assertHttpsUrl(endpoint, "authorizationEndpoint");
|
|
5085
|
+
assertHttpsUrl(issuer, "issuer");
|
|
5086
|
+
const codeVerifier = options.codeVerifier ?? createCodeVerifier();
|
|
5087
|
+
assertCodeVerifier(codeVerifier);
|
|
5088
|
+
const state = options.state ?? createRandomValue(16);
|
|
5089
|
+
assertNonEmptyString(state, "state");
|
|
5090
|
+
const url = new URL(endpoint);
|
|
5091
|
+
url.searchParams.set("response_type", "code");
|
|
5092
|
+
url.searchParams.set("client_id", options.clientId);
|
|
5093
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
5094
|
+
url.searchParams.set("scope", scope);
|
|
5095
|
+
url.searchParams.set("state", state);
|
|
5096
|
+
url.searchParams.set("code_challenge", codeChallengeFor(codeVerifier));
|
|
5097
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
5098
|
+
const { resource } = this.resourceParam(options.resource);
|
|
5099
|
+
if (resource !== void 0) url.searchParams.set("resource", resource);
|
|
5100
|
+
const wantsNonce = options.nonce === void 0 ? options.scopes.includes("openid") : options.nonce !== null;
|
|
5101
|
+
const nonce = wantsNonce ? options.nonce ?? createRandomValue(16) : void 0;
|
|
5102
|
+
if (nonce !== void 0) {
|
|
5103
|
+
assertNonEmptyString(nonce, "nonce");
|
|
5104
|
+
url.searchParams.set("nonce", nonce);
|
|
5105
|
+
}
|
|
5106
|
+
if (options.prompt !== void 0) {
|
|
5107
|
+
assertNonEmptyString(options.prompt, "prompt");
|
|
5108
|
+
url.searchParams.set("prompt", options.prompt);
|
|
5109
|
+
}
|
|
5110
|
+
this.logger.info("Built OAuth authorization URL");
|
|
5111
|
+
const request = {
|
|
5112
|
+
url: url.toString(),
|
|
5113
|
+
state,
|
|
5114
|
+
codeVerifier,
|
|
5115
|
+
issuer
|
|
5116
|
+
};
|
|
5117
|
+
if (nonce !== void 0) request.nonce = nonce;
|
|
5118
|
+
return request;
|
|
5119
|
+
}
|
|
5120
|
+
/**
|
|
5121
|
+
* Validate the authorization response that lands on your redirect URI and
|
|
5122
|
+
* return the code to exchange.
|
|
5123
|
+
*
|
|
5124
|
+
* Checks, in order and before anything else is trusted: `state` equals the
|
|
5125
|
+
* value from {@link OAuthResource.createAuthorizationUrl} (constant-time),
|
|
5126
|
+
* `iss` is present and equals the expected issuer, and only then whether
|
|
5127
|
+
* the server reported an error. A declined consent arrives as
|
|
5128
|
+
* `?error=access_denied`, not as a failed HTTP request.
|
|
5129
|
+
*
|
|
5130
|
+
* The `iss` check is strict because the authorization server advertises
|
|
5131
|
+
* RFC 9207 support and always sends the parameter: a missing `iss` is
|
|
5132
|
+
* treated exactly like a wrong one. Omit `expected.issuer` only if
|
|
5133
|
+
* something between the browser and your handler strips query parameters.
|
|
5134
|
+
*
|
|
5135
|
+
* This performs no network I/O.
|
|
5136
|
+
*
|
|
5137
|
+
* @param params - The callback's query parameters. Accepts an Express-style
|
|
5138
|
+
* `req.query` record, a `URLSearchParams`, a `URL`, a full callback URL
|
|
5139
|
+
* string, or a bare `a=b&c=d` query string.
|
|
5140
|
+
* @param expected - The stored {@link IOAuthAuthorizationRequest} (or any
|
|
5141
|
+
* object carrying its `state` and `issuer`).
|
|
5142
|
+
* @returns The validated response:
|
|
5143
|
+
* ```jsonc
|
|
5144
|
+
* {
|
|
5145
|
+
* "code": "def50200a1b2c3…",
|
|
5146
|
+
* "state": "8Xv2rQ7mJt0aLpKcWn4dZg",
|
|
5147
|
+
* "issuer": "https://auth.assinafy.com.br"
|
|
5148
|
+
* }
|
|
5149
|
+
* ```
|
|
5150
|
+
* @throws {ValidationError} If `state` is missing or does not match, `iss`
|
|
5151
|
+
* is absent or disagrees with the expected issuer, or a successful response
|
|
5152
|
+
* carries no `code`. In every case the response is not yours — stop, do not
|
|
5153
|
+
* exchange.
|
|
5154
|
+
* @throws {OAuthError} If the server returned `error` (e.g.
|
|
5155
|
+
* `access_denied`, `invalid_scope`, `invalid_request`,
|
|
5156
|
+
* `unsupported_response_type`, `invalid_target`).
|
|
5157
|
+
*
|
|
5158
|
+
* @example
|
|
5159
|
+
* ```ts
|
|
5160
|
+
* app.get('/oauth/callback', async (req, res) => {
|
|
5161
|
+
* const stored = req.session.oauth;
|
|
5162
|
+
* const { code } = client.oauth.readAuthorizationCallback(req.query, stored);
|
|
5163
|
+
* const tokens = await client.oauth.exchangeCode({
|
|
5164
|
+
* code,
|
|
5165
|
+
* codeVerifier: stored.codeVerifier,
|
|
5166
|
+
* redirectUri: 'https://myapp.com/oauth/callback',
|
|
5167
|
+
* clientId: process.env.ASSINAFY_CLIENT_ID!,
|
|
5168
|
+
* clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
|
|
5169
|
+
* });
|
|
5170
|
+
* });
|
|
5171
|
+
* ```
|
|
5172
|
+
*/
|
|
5173
|
+
readAuthorizationCallback(params, expected) {
|
|
5174
|
+
assertRecord(expected, "expected authorization request");
|
|
5175
|
+
assertNonEmptyString(expected.state, "expected.state");
|
|
5176
|
+
const query = toSearchParams(params);
|
|
5177
|
+
const state = query.get("state");
|
|
5178
|
+
if (state === null || !constantTimeEquals(state, expected.state)) {
|
|
5179
|
+
throw new ValidationError(
|
|
5180
|
+
"OAuth callback state does not match the stored authorization request"
|
|
5181
|
+
);
|
|
5182
|
+
}
|
|
5183
|
+
const issuer = query.get("iss") ?? void 0;
|
|
5184
|
+
if (expected.issuer !== void 0) {
|
|
5185
|
+
if (issuer === void 0 || normaliseIssuer(issuer) !== normaliseIssuer(expected.issuer)) {
|
|
5186
|
+
throw new ValidationError(
|
|
5187
|
+
"OAuth callback issuer is missing or does not match the expected issuer",
|
|
5188
|
+
{ expected: expected.issuer, received: issuer ?? null }
|
|
5189
|
+
);
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
const error = query.get("error");
|
|
5193
|
+
if (error !== null && error.length > 0) {
|
|
5194
|
+
throw new OAuthError(error, query.get("error_description"), 400, {
|
|
5195
|
+
error,
|
|
5196
|
+
error_description: query.get("error_description")
|
|
5197
|
+
});
|
|
5198
|
+
}
|
|
5199
|
+
const code = query.get("code");
|
|
5200
|
+
if (code === null || code.length === 0) {
|
|
5201
|
+
throw new ValidationError("OAuth callback carries neither a code nor an error");
|
|
5202
|
+
}
|
|
5203
|
+
const result = { code, state };
|
|
5204
|
+
if (issuer !== void 0) result.issuer = issuer;
|
|
5205
|
+
return result;
|
|
5206
|
+
}
|
|
5207
|
+
/**
|
|
5208
|
+
* Exchange an authorization code for tokens
|
|
5209
|
+
* (`POST /oauth/token`, `grant_type=authorization_code`).
|
|
5210
|
+
*
|
|
5211
|
+
* Run this on your server: the code is single-use and expires **60 seconds**
|
|
5212
|
+
* after approval, and a confidential application's secret must never reach
|
|
5213
|
+
* a browser. Every value must match the authorization request exactly, or
|
|
5214
|
+
* the API answers `invalid_grant`.
|
|
5215
|
+
*
|
|
5216
|
+
* @param options - Exchange options.
|
|
5217
|
+
* @param options.code - The code from
|
|
5218
|
+
* {@link OAuthResource.readAuthorizationCallback}.
|
|
5219
|
+
* @param options.codeVerifier - The verifier stored alongside the request.
|
|
5220
|
+
* @param options.redirectUri - The same redirect URI that was authorized.
|
|
5221
|
+
* @param options.clientId - The application's `client_id`.
|
|
5222
|
+
* @param options.clientSecret - The `client_secret`, for confidential
|
|
5223
|
+
* applications only. Public applications omit it and rely on PKCE.
|
|
5224
|
+
* @param options.resource - The same RFC 8707 resource indicator sent to
|
|
5225
|
+
* the authorization endpoint. Defaults to this API's origin; pass `null` to
|
|
5226
|
+
* omit it. A value disagreeing with the authorized one fails with
|
|
5227
|
+
* `invalid_target`.
|
|
5228
|
+
* @returns The token set — a flat object, **not** the API's usual envelope:
|
|
5229
|
+
* ```jsonc
|
|
5230
|
+
* {
|
|
5231
|
+
* "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
|
|
5232
|
+
* "token_type": "Bearer",
|
|
5233
|
+
* "expires_in": 3600,
|
|
5234
|
+
* "scope": "documents:read documents:write",
|
|
5235
|
+
* "refresh_token": "def5020088c2…", // only with offline_access
|
|
5236
|
+
* "id_token": "eyJraWQiOiJEQlR0S0…" // only with openid
|
|
5237
|
+
* }
|
|
5238
|
+
* ```
|
|
5239
|
+
* Read `scope` rather than assuming every requested permission was granted.
|
|
5240
|
+
* @throws {ValidationError} If an argument is missing or malformed, or a
|
|
5241
|
+
* `2xx` response carries no `access_token`.
|
|
5242
|
+
* @throws {OAuthError} `invalid_grant` for a spent, expired, replayed or
|
|
5243
|
+
* mismatched code; `invalid_client` for a bad `client_id`/`client_secret`;
|
|
5244
|
+
* `invalid_target` for a `resource` mismatch.
|
|
5245
|
+
*
|
|
5246
|
+
* @example
|
|
5247
|
+
* ```ts
|
|
5248
|
+
* const tokens = await client.oauth.exchangeCode({
|
|
5249
|
+
* code,
|
|
5250
|
+
* codeVerifier: session.oauth.codeVerifier,
|
|
5251
|
+
* redirectUri: 'https://myapp.com/oauth/callback',
|
|
5252
|
+
* clientId: process.env.ASSINAFY_CLIENT_ID!,
|
|
5253
|
+
* clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
|
|
5254
|
+
* });
|
|
5255
|
+
* ```
|
|
5256
|
+
*/
|
|
5257
|
+
async exchangeCode(options) {
|
|
5258
|
+
assertRecord(options, "code exchange options");
|
|
5259
|
+
assertNonEmptyString(options.code, "code");
|
|
5260
|
+
assertCodeVerifier(options.codeVerifier);
|
|
5261
|
+
assertRedirectUri(options.redirectUri);
|
|
5262
|
+
return this.requestToken("Failed to exchange the OAuth authorization code", {
|
|
5263
|
+
grant_type: "authorization_code",
|
|
5264
|
+
code: options.code,
|
|
5265
|
+
redirect_uri: options.redirectUri,
|
|
5266
|
+
code_verifier: options.codeVerifier,
|
|
5267
|
+
...this.clientAuth(options),
|
|
5268
|
+
...this.resourceParam(options.resource)
|
|
5269
|
+
});
|
|
5270
|
+
}
|
|
5271
|
+
/**
|
|
5272
|
+
* Renew an access token (`POST /oauth/token`, `grant_type=refresh_token`).
|
|
5273
|
+
*
|
|
5274
|
+
* Access tokens last one hour; refresh tokens are available only when
|
|
5275
|
+
* `offline_access` was requested and granted.
|
|
5276
|
+
*
|
|
5277
|
+
* **Refresh tokens rotate.** Every call returns a new one and retires the
|
|
5278
|
+
* one you sent, and a replayed refresh token cannot be told apart from a
|
|
5279
|
+
* stolen one — so the server ends the entire connection and the user must
|
|
5280
|
+
* reconnect. Therefore: persist `refresh_token` from the response before
|
|
5281
|
+
* doing anything else with it, treat a timeout as "it may have succeeded"
|
|
5282
|
+
* and re-read your stored token instead of retrying blindly, and never run
|
|
5283
|
+
* two refreshes concurrently for one connection.
|
|
5284
|
+
*
|
|
5285
|
+
* Refreshing does not extend the connection's 30-day life.
|
|
5286
|
+
*
|
|
5287
|
+
* @param options - Refresh options.
|
|
5288
|
+
* @param options.refreshToken - The current refresh token.
|
|
5289
|
+
* @param options.clientId - The application's `client_id`.
|
|
5290
|
+
* @param options.clientSecret - The `client_secret`, for confidential
|
|
5291
|
+
* applications only.
|
|
5292
|
+
* @param options.resource - RFC 8707 resource indicator. Defaults to this
|
|
5293
|
+
* API's origin; pass `null` to omit it.
|
|
5294
|
+
* @returns A fresh token set, identical in shape to
|
|
5295
|
+
* {@link OAuthResource.exchangeCode}:
|
|
5296
|
+
* ```jsonc
|
|
5297
|
+
* {
|
|
5298
|
+
* "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
|
|
5299
|
+
* "token_type": "Bearer",
|
|
5300
|
+
* "expires_in": 3600,
|
|
5301
|
+
* "scope": "documents:read documents:write",
|
|
5302
|
+
* "refresh_token": "def50200f1e2…" // NEW — persist it immediately
|
|
5303
|
+
* }
|
|
5304
|
+
* ```
|
|
5305
|
+
* @throws {ValidationError} If an argument is missing, or a `2xx` response
|
|
5306
|
+
* carries no `access_token`.
|
|
5307
|
+
* @throws {OAuthError} `invalid_grant` when the refresh token was already
|
|
5308
|
+
* used, expired, or the user reconnected with different permissions — ask
|
|
5309
|
+
* the user to reconnect. `invalid_client` for bad client credentials.
|
|
5310
|
+
*
|
|
5311
|
+
* @example
|
|
5312
|
+
* ```ts
|
|
5313
|
+
* const tokens = await client.oauth.refreshToken({
|
|
5314
|
+
* refreshToken: connection.refreshToken,
|
|
5315
|
+
* clientId: process.env.ASSINAFY_CLIENT_ID!,
|
|
5316
|
+
* clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
|
|
5317
|
+
* });
|
|
5318
|
+
* await connection.save({ refreshToken: tokens.refresh_token });
|
|
5319
|
+
* ```
|
|
5320
|
+
*/
|
|
5321
|
+
async refreshToken(options) {
|
|
5322
|
+
assertRecord(options, "refresh options");
|
|
5323
|
+
assertNonEmptyString(options.refreshToken, "refreshToken");
|
|
5324
|
+
return this.requestToken("Failed to refresh the OAuth access token", {
|
|
5325
|
+
grant_type: "refresh_token",
|
|
5326
|
+
refresh_token: options.refreshToken,
|
|
5327
|
+
...this.clientAuth(options),
|
|
5328
|
+
...this.resourceParam(options.resource)
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
5331
|
+
/**
|
|
5332
|
+
* Revoke an access or refresh token (`POST /oauth/revoke`, RFC 7009).
|
|
5333
|
+
*
|
|
5334
|
+
* Call this when a user disconnects your app, instead of only deleting your
|
|
5335
|
+
* copy of the token. Revoking a refresh token ends the whole connection.
|
|
5336
|
+
*
|
|
5337
|
+
* Every token outcome answers `200` — unknown, malformed and
|
|
5338
|
+
* already-revoked included — so the endpoint cannot be used to probe
|
|
5339
|
+
* whether a token exists. Only failed client authentication returns `401`.
|
|
5340
|
+
*
|
|
5341
|
+
* @param options - Revocation options.
|
|
5342
|
+
* @param options.token - The access or refresh token to revoke.
|
|
5343
|
+
* @param options.clientId - The application's `client_id`.
|
|
5344
|
+
* @param options.clientSecret - The `client_secret`, for confidential
|
|
5345
|
+
* applications only.
|
|
5346
|
+
* @param options.tokenTypeHint - Optional `access_token` or
|
|
5347
|
+
* `refresh_token` hint that lets the server skip a lookup.
|
|
5348
|
+
* @returns Nothing; resolves once the API acknowledges the request.
|
|
5349
|
+
* Request body:
|
|
5350
|
+
* ```jsonc
|
|
5351
|
+
* {
|
|
5352
|
+
* "token": "def50200f1e2…",
|
|
5353
|
+
* "token_type_hint": "refresh_token",
|
|
5354
|
+
* "client_id": "cli_1a2b3c",
|
|
5355
|
+
* "client_secret": "…"
|
|
5356
|
+
* }
|
|
5357
|
+
* ```
|
|
5358
|
+
* @throws {ValidationError} If `token` or `clientId` is missing, or
|
|
5359
|
+
* `tokenTypeHint` is not one of the two documented values.
|
|
5360
|
+
* @throws {OAuthError} `invalid_client` when client authentication fails.
|
|
5361
|
+
*
|
|
5362
|
+
* @example
|
|
5363
|
+
* ```ts
|
|
5364
|
+
* await client.oauth.revokeToken({
|
|
5365
|
+
* token: connection.refreshToken,
|
|
5366
|
+
* tokenTypeHint: 'refresh_token',
|
|
5367
|
+
* clientId: process.env.ASSINAFY_CLIENT_ID!,
|
|
5368
|
+
* clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
|
|
5369
|
+
* });
|
|
5370
|
+
* ```
|
|
5371
|
+
*/
|
|
5372
|
+
async revokeToken(options) {
|
|
5373
|
+
assertRecord(options, "revocation options");
|
|
5374
|
+
assertNonEmptyString(options.token, "token");
|
|
5375
|
+
if (options.tokenTypeHint !== void 0 && options.tokenTypeHint !== "access_token" && options.tokenTypeHint !== "refresh_token") {
|
|
5376
|
+
throw new ValidationError("tokenTypeHint must be access_token or refresh_token");
|
|
5377
|
+
}
|
|
5378
|
+
const body = cleanParams({
|
|
5379
|
+
token: options.token,
|
|
5380
|
+
token_type_hint: options.tokenTypeHint,
|
|
5381
|
+
...this.clientAuth(options)
|
|
5382
|
+
});
|
|
5383
|
+
try {
|
|
5384
|
+
await this.callVoid(
|
|
5385
|
+
"Failed to revoke the OAuth token",
|
|
5386
|
+
() => this.publicHttp.post("/oauth/revoke", body)
|
|
5387
|
+
);
|
|
5388
|
+
} catch (error) {
|
|
5389
|
+
throw OAuthError.upgrade(error);
|
|
5390
|
+
}
|
|
5391
|
+
}
|
|
5392
|
+
/**
|
|
5393
|
+
* Read the OpenID Connect claims of the user who authorized a token
|
|
5394
|
+
* (`GET /oauth/userinfo`).
|
|
5395
|
+
*
|
|
5396
|
+
* Requires the `openid` scope; `name` additionally requires `profile` and
|
|
5397
|
+
* `email`/`email_verified` require `email`. Per OIDC Core §5.3.2 the
|
|
5398
|
+
* response is a flat claims object, not this API's usual envelope.
|
|
5399
|
+
*
|
|
5400
|
+
* @param accessToken - Token to introspect. Omit to use the credential the
|
|
5401
|
+
* client was constructed with (`token` or `apiKey`).
|
|
5402
|
+
* @returns The claims the granted scopes allow:
|
|
5403
|
+
* ```jsonc
|
|
5404
|
+
* {
|
|
5405
|
+
* "sub": "d6zqpbyog2v3xvxerwn8la94",
|
|
5406
|
+
* "name": "Maria Silva",
|
|
5407
|
+
* "email": "maria@example.com",
|
|
5408
|
+
* "email_verified": true
|
|
5409
|
+
* }
|
|
5410
|
+
* ```
|
|
5411
|
+
* `sub` is the stable user identifier; the rest are `null` when their scope
|
|
5412
|
+
* was not granted.
|
|
5413
|
+
* @throws {ValidationError} If `accessToken` is supplied but empty.
|
|
5414
|
+
* @throws {ApiError} `401` when the token is missing, expired or revoked;
|
|
5415
|
+
* `403` when the `openid` scope was not granted — its `WWW-Authenticate`
|
|
5416
|
+
* header names the scope to reconnect with.
|
|
5417
|
+
*
|
|
5418
|
+
* @example
|
|
5419
|
+
* ```ts
|
|
5420
|
+
* const who = await client.oauth.getUserInfo(tokens.access_token);
|
|
5421
|
+
* console.log(who.sub, who.email);
|
|
5422
|
+
* ```
|
|
5423
|
+
*/
|
|
5424
|
+
async getUserInfo(accessToken) {
|
|
5425
|
+
if (accessToken === void 0) {
|
|
5426
|
+
return this.call(
|
|
5427
|
+
"Failed to fetch OAuth userinfo",
|
|
5428
|
+
() => this.http.get("/oauth/userinfo")
|
|
5429
|
+
);
|
|
5430
|
+
}
|
|
5431
|
+
assertNonEmptyString(accessToken, "accessToken");
|
|
5432
|
+
return this.call(
|
|
5433
|
+
"Failed to fetch OAuth userinfo",
|
|
5434
|
+
() => this.publicHttp.get("/oauth/userinfo", {
|
|
5435
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
5436
|
+
})
|
|
5437
|
+
);
|
|
5438
|
+
}
|
|
5439
|
+
/** POST the token endpoint and assert the response actually carries a token. */
|
|
5440
|
+
async requestToken(label, body) {
|
|
5441
|
+
let tokens;
|
|
5442
|
+
try {
|
|
5443
|
+
tokens = await this.call(
|
|
5444
|
+
label,
|
|
5445
|
+
() => this.publicHttp.post("/oauth/token", cleanParams(body))
|
|
5446
|
+
);
|
|
5447
|
+
} catch (error) {
|
|
5448
|
+
throw OAuthError.upgrade(error);
|
|
5449
|
+
}
|
|
5450
|
+
if (typeof tokens?.access_token !== "string" || tokens.access_token.length === 0) {
|
|
5451
|
+
throw new ValidationError(`${label}: the token endpoint returned no access_token`, {
|
|
5452
|
+
response: tokens
|
|
5453
|
+
});
|
|
5454
|
+
}
|
|
5455
|
+
return tokens;
|
|
5456
|
+
}
|
|
5457
|
+
/** `client_secret_post` credentials, omitting the secret for public clients. */
|
|
5458
|
+
clientAuth(options) {
|
|
5459
|
+
assertNonEmptyString(options.clientId, "clientId");
|
|
5460
|
+
if (options.clientSecret !== void 0) {
|
|
5461
|
+
assertNonEmptyString(options.clientSecret, "clientSecret");
|
|
5462
|
+
}
|
|
5463
|
+
return { client_id: options.clientId, client_secret: options.clientSecret };
|
|
5464
|
+
}
|
|
5465
|
+
/**
|
|
5466
|
+
* Resolve the optional RFC 8707 `resource` indicator.
|
|
5467
|
+
*
|
|
5468
|
+
* Defaults to the configured API origin, which is what this API publishes
|
|
5469
|
+
* as its `resource`. A loopback `http://` base URL — the shape used by mock
|
|
5470
|
+
* servers and the packed-consumer smoke test — has no valid resource
|
|
5471
|
+
* identifier, so the parameter is simply omitted rather than rejected; an
|
|
5472
|
+
* explicitly supplied value is still required to be `https`.
|
|
5473
|
+
*/
|
|
5474
|
+
resourceParam(resource) {
|
|
5475
|
+
if (resource === void 0) {
|
|
5476
|
+
const origin = this.apiOrigin();
|
|
5477
|
+
return origin.startsWith("https:") ? { resource: origin } : {};
|
|
5478
|
+
}
|
|
5479
|
+
if (resource === null) return {};
|
|
5480
|
+
assertHttpsUrl(resource, "resource");
|
|
5481
|
+
return { resource };
|
|
5482
|
+
}
|
|
5483
|
+
/** Discover which authorization server may issue tokens for this API. */
|
|
5484
|
+
async defaultIssuer() {
|
|
5485
|
+
const metadata = await this.getProtectedResourceMetadata();
|
|
5486
|
+
const issuer = metadata?.authorization_servers?.[0];
|
|
5487
|
+
if (typeof issuer !== "string" || issuer.length === 0) {
|
|
5488
|
+
throw new ValidationError(
|
|
5489
|
+
"Protected-resource metadata lists no authorization server",
|
|
5490
|
+
{ metadata }
|
|
5491
|
+
);
|
|
5492
|
+
}
|
|
5493
|
+
return issuer;
|
|
5494
|
+
}
|
|
5495
|
+
/**
|
|
5496
|
+
* Origin of the configured API host.
|
|
5497
|
+
*
|
|
5498
|
+
* The `.well-known` document and the RFC 8707 resource indicator both sit
|
|
5499
|
+
* at the host root, while `baseUrl` points at `/v1`.
|
|
5500
|
+
*/
|
|
5501
|
+
apiOrigin() {
|
|
5502
|
+
const baseUrl = this.publicHttp.defaults.baseURL;
|
|
5503
|
+
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
|
|
5504
|
+
throw new ValidationError("The client has no base URL to derive the API origin from");
|
|
5505
|
+
}
|
|
5506
|
+
return new URL(baseUrl).origin;
|
|
5507
|
+
}
|
|
5508
|
+
};
|
|
5509
|
+
function createCodeVerifier() {
|
|
5510
|
+
return randomBytes(32).toString("base64url");
|
|
5511
|
+
}
|
|
5512
|
+
function createRandomValue(bytes) {
|
|
5513
|
+
return randomBytes(bytes).toString("base64url");
|
|
5514
|
+
}
|
|
5515
|
+
function codeChallengeFor(codeVerifier) {
|
|
5516
|
+
return createHash("sha256").update(codeVerifier).digest("base64url");
|
|
5517
|
+
}
|
|
5518
|
+
function assertCodeVerifier(value) {
|
|
5519
|
+
if (typeof value !== "string" || !CODE_VERIFIER_PATTERN.test(value)) {
|
|
5520
|
+
throw new ValidationError(
|
|
5521
|
+
"codeVerifier must be 43-128 characters from A-Z a-z 0-9 - . _ ~"
|
|
5522
|
+
);
|
|
5523
|
+
}
|
|
5524
|
+
}
|
|
5525
|
+
function assertRedirectUri(value) {
|
|
5526
|
+
const uri = assertHttpsUrl(value, "redirectUri");
|
|
5527
|
+
if (uri.includes("#")) {
|
|
5528
|
+
throw new ValidationError("redirectUri must not contain a fragment");
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
function assertHttpsUrl(value, label) {
|
|
5532
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
5533
|
+
throw new ValidationError(`${label} must be an absolute https URL`);
|
|
5534
|
+
}
|
|
5535
|
+
let url;
|
|
5536
|
+
try {
|
|
5537
|
+
url = new URL(value);
|
|
5538
|
+
} catch {
|
|
5539
|
+
throw new ValidationError(`${label} must be an absolute https URL`);
|
|
5540
|
+
}
|
|
5541
|
+
if (url.protocol !== "https:") {
|
|
5542
|
+
throw new ValidationError(`${label} must be an absolute https URL`);
|
|
5543
|
+
}
|
|
5544
|
+
return value;
|
|
5545
|
+
}
|
|
5546
|
+
function assertScopes(scopes) {
|
|
5547
|
+
if (!Array.isArray(scopes) || scopes.length === 0) {
|
|
5548
|
+
throw new ValidationError("scopes must be a non-empty array of scope strings");
|
|
5549
|
+
}
|
|
5550
|
+
for (const scope of scopes) {
|
|
5551
|
+
if (typeof scope !== "string" || scope.trim().length === 0 || /\s/u.test(scope)) {
|
|
5552
|
+
throw new ValidationError("each scope must be a non-empty string without whitespace");
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
return [...new Set(scopes)].join(" ");
|
|
5556
|
+
}
|
|
5557
|
+
function normaliseIssuer(value) {
|
|
5558
|
+
return typeof value === "string" ? value.replace(/\/+$/u, "") : "";
|
|
5559
|
+
}
|
|
5560
|
+
function constantTimeEquals(left, right) {
|
|
5561
|
+
const a = Buffer.from(left, "utf8");
|
|
5562
|
+
const b = Buffer.from(right, "utf8");
|
|
5563
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
5564
|
+
}
|
|
5565
|
+
function toSearchParams(params) {
|
|
5566
|
+
if (params instanceof URLSearchParams) return params;
|
|
5567
|
+
if (params instanceof URL) return params.searchParams;
|
|
5568
|
+
if (typeof params === "string") {
|
|
5569
|
+
return params.includes("://") ? new URL(params).searchParams : new URLSearchParams(params.replace(/^\?/u, ""));
|
|
5570
|
+
}
|
|
5571
|
+
assertRecord(params, "callback parameters");
|
|
5572
|
+
const search = new URLSearchParams();
|
|
5573
|
+
for (const [key, value] of Object.entries(params)) {
|
|
5574
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
5575
|
+
if (typeof first === "string") search.set(key, first);
|
|
5576
|
+
}
|
|
5577
|
+
return search;
|
|
5578
|
+
}
|
|
5579
|
+
|
|
4783
5580
|
// src/resources/fields.ts
|
|
4784
5581
|
var FieldsResource = class extends BaseResource {
|
|
4785
5582
|
/**
|
|
@@ -6003,7 +6800,7 @@ function validateNotificationPreferences(preferences) {
|
|
|
6003
6800
|
}
|
|
6004
6801
|
|
|
6005
6802
|
// src/support/webhook-verifier.ts
|
|
6006
|
-
import { createHmac, timingSafeEqual } from "crypto";
|
|
6803
|
+
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
6007
6804
|
var WebhookVerifier = class {
|
|
6008
6805
|
webhookSecret;
|
|
6009
6806
|
/**
|
|
@@ -6045,7 +6842,7 @@ var WebhookVerifier = class {
|
|
|
6045
6842
|
const expected = createHmac("sha256", this.webhookSecret).update(buf).digest();
|
|
6046
6843
|
const actual = Buffer.from(provided, "hex");
|
|
6047
6844
|
try {
|
|
6048
|
-
return
|
|
6845
|
+
return timingSafeEqual2(expected, actual);
|
|
6049
6846
|
} catch {
|
|
6050
6847
|
return false;
|
|
6051
6848
|
}
|
|
@@ -6130,6 +6927,7 @@ var AssinafyClient = class _AssinafyClient {
|
|
|
6130
6927
|
templates;
|
|
6131
6928
|
tags;
|
|
6132
6929
|
auth;
|
|
6930
|
+
oauth;
|
|
6133
6931
|
fields;
|
|
6134
6932
|
signerDocuments;
|
|
6135
6933
|
users;
|
|
@@ -6236,6 +7034,12 @@ var AssinafyClient = class _AssinafyClient {
|
|
|
6236
7034
|
this.logger,
|
|
6237
7035
|
this.publicAxiosInstance
|
|
6238
7036
|
);
|
|
7037
|
+
this.oauth = new OAuthResource(
|
|
7038
|
+
this.axiosInstance,
|
|
7039
|
+
void 0,
|
|
7040
|
+
this.logger,
|
|
7041
|
+
this.publicAxiosInstance
|
|
7042
|
+
);
|
|
6239
7043
|
this.fields = new FieldsResource(this.axiosInstance, this.defaultAccountId, this.logger);
|
|
6240
7044
|
this.signerDocuments = new SignerDocumentsResource(
|
|
6241
7045
|
this.publicAxiosInstance,
|
|
@@ -6672,6 +7476,8 @@ export {
|
|
|
6672
7476
|
MAX_LIST_PAGE_SIZE,
|
|
6673
7477
|
MAX_UPLOAD_BYTES,
|
|
6674
7478
|
NetworkError,
|
|
7479
|
+
OAuthError,
|
|
7480
|
+
OAuthResource,
|
|
6675
7481
|
SDK_USER_AGENT,
|
|
6676
7482
|
SignerDocumentsResource,
|
|
6677
7483
|
SignerResource,
|
|
@@ -6682,5 +7488,6 @@ export {
|
|
|
6682
7488
|
WebhookResource,
|
|
6683
7489
|
WebhookVerifier,
|
|
6684
7490
|
WorkspaceResource,
|
|
6685
|
-
buildAssignmentPayload
|
|
7491
|
+
buildAssignmentPayload,
|
|
7492
|
+
parseWwwAuthenticate
|
|
6686
7493
|
};
|