@assinafy/sdk 2.1.2 → 2.3.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/dist/index.mjs CHANGED
@@ -3,6 +3,12 @@ import axios3 from "axios";
3
3
  import { setTimeout as delay } from "timers/promises";
4
4
 
5
5
  // src/errors.ts
6
+ var FALLBACK_MESSAGE = "API request failed";
7
+ var MAX_MESSAGE_LENGTH = 500;
8
+ function summarize(text) {
9
+ const collapsed = text.replaceAll(/\s+/gu, " ");
10
+ return collapsed.length > MAX_MESSAGE_LENGTH ? `${collapsed.slice(0, MAX_MESSAGE_LENGTH)}\u2026` : collapsed;
11
+ }
6
12
  var AssinafyError = class extends Error {
7
13
  context;
8
14
  /**
@@ -26,6 +32,29 @@ var AssinafyError = class extends Error {
26
32
  var ApiError = class _ApiError extends AssinafyError {
27
33
  statusCode;
28
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;
29
58
  /**
30
59
  * Create an error representing a non-success API response.
31
60
  *
@@ -44,9 +73,14 @@ var ApiError = class _ApiError extends AssinafyError {
44
73
  * Convert a status/body pair into an {@link ApiError}.
45
74
  *
46
75
  * @param statusCode - Non-success HTTP response status.
47
- * @param responseData - Parsed API body. String `message` takes priority,
48
- * followed by string `error`, then the stable fallback message.
49
- * @returns An `ApiError` retaining the original response body.
76
+ * @param responseData - API body. For a JSON object, string `message` takes
77
+ * priority, followed by string `error`. A non-JSON body (a proxy's
78
+ * `text/plain` or HTML error page) is used verbatim rather than discarded —
79
+ * otherwise the only failures reported as the generic fallback would be the
80
+ * ones with no structured body to explain them. Anything else falls back to
81
+ * the stable message.
82
+ * @returns An `ApiError` retaining the original response body in
83
+ * {@link ApiError.responseData}; `message` is truncated for legibility.
50
84
  *
51
85
  * @example
52
86
  * ```ts
@@ -55,13 +89,66 @@ var ApiError = class _ApiError extends AssinafyError {
55
89
  * ```
56
90
  */
57
91
  static fromResponse(statusCode, responseData) {
92
+ if (typeof responseData === "string") {
93
+ const text = responseData.trim();
94
+ return new _ApiError(text ? summarize(text) : FALLBACK_MESSAGE, statusCode, responseData);
95
+ }
58
96
  const data = responseData ?? {};
59
97
  const rawMessage = data["message"];
60
98
  const rawError = data["error"];
61
- const message = typeof rawMessage === "string" && rawMessage.length > 0 ? rawMessage : typeof rawError === "string" ? rawError : "API request failed";
99
+ const message = typeof rawMessage === "string" && rawMessage.length > 0 ? rawMessage : typeof rawError === "string" ? rawError : FALLBACK_MESSAGE;
62
100
  return new _ApiError(message, statusCode, responseData);
63
101
  }
64
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
+ };
65
152
  var ValidationError = class extends AssinafyError {
66
153
  errors;
67
154
  /**
@@ -101,6 +188,43 @@ var NetworkError = class extends AssinafyError {
101
188
 
102
189
  // src/utils.ts
103
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
104
228
  var SAFE_LOG_NUMBER_FIELDS = /* @__PURE__ */ new Set([
105
229
  "attempt",
106
230
  "attempts",
@@ -133,7 +257,7 @@ function decodeBinaryErrorBody(data) {
133
257
  try {
134
258
  return JSON.parse(text);
135
259
  } catch {
136
- return text.length > 0 ? { message: text } : null;
260
+ return text.length > 0 ? text : null;
137
261
  }
138
262
  }
139
263
  function toSdkError(error, fallbackMessage) {
@@ -144,7 +268,12 @@ function toSdkError(error, fallbackMessage) {
144
268
  const status = error.response?.status;
145
269
  if (status) {
146
270
  const body = decodeBinaryErrorBody(error.response?.data ?? null);
147
- return ApiError.fromResponse(status, body ?? null);
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;
148
277
  }
149
278
  const cause = sanitiseNetworkCause(error);
150
279
  return new NetworkError(`${fallbackMessage}: ${cause.message}`, { cause });
@@ -214,6 +343,18 @@ function sanitiseNetworkCause(error) {
214
343
  function redactSensitiveErrorText(message) {
215
344
  return message.replace(SENSITIVE_ERROR_VALUE_RE, "$1[REDACTED]").replace(/(https?:\/\/)[^/@\s]+:[^/@\s]+@/gi, "$1[REDACTED]@");
216
345
  }
346
+ function isEmail(value) {
347
+ return typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value);
348
+ }
349
+ function assertEmail(value, label = "email") {
350
+ if (!isEmail(value)) {
351
+ throw new ValidationError(`${label} must be a valid email address`, { [label]: value });
352
+ }
353
+ }
354
+ function isE164PhoneNumber(value) {
355
+ return typeof value === "string" && /^\+[1-9]\d{1,14}$/u.test(value);
356
+ }
357
+ var MAX_LIST_PAGE_SIZE = 50;
217
358
  function assertRecord(value, label) {
218
359
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
219
360
  throw new ValidationError(`${label} must be an object`);
@@ -282,22 +423,6 @@ function cleanListParams(params) {
282
423
  return out;
283
424
  }
284
425
 
285
- // src/support/headers.ts
286
- function readHeader(headers, name) {
287
- if (!headers) return void 0;
288
- const lower = name.toLowerCase();
289
- for (const [key, value] of Object.entries(headers)) {
290
- if (key.toLowerCase() === lower && value != null) {
291
- const first = Array.isArray(value) ? value[0] : value;
292
- if (typeof first === "string" || typeof first === "number" || typeof first === "boolean") {
293
- return String(first);
294
- }
295
- return void 0;
296
- }
297
- }
298
- return void 0;
299
- }
300
-
301
426
  // src/support/retry.ts
302
427
  function retryDelayFromHeaders(headers) {
303
428
  const retryAfter = readHeader(headers, "retry-after");
@@ -449,7 +574,7 @@ import axios2 from "axios";
449
574
  // package.json
450
575
  var package_default = {
451
576
  name: "@assinafy/sdk",
452
- version: "2.1.2",
577
+ version: "2.3.0",
453
578
  packageManager: "bun@1.4.0",
454
579
  description: "TypeScript SDK for Assinafy API - Digital signature platform",
455
580
  type: "commonjs",
@@ -536,6 +661,7 @@ var package_default = {
536
661
  "dist",
537
662
  "docs",
538
663
  "README.md",
664
+ "README.en.md",
539
665
  "CHANGELOG.md",
540
666
  "SECURITY.md",
541
667
  "LICENSE"
@@ -787,14 +913,34 @@ function toInt(value) {
787
913
  var ASSIGNMENT_METHODS = /* @__PURE__ */ new Set(["virtual", "collect"]);
788
914
  var VERIFICATION_METHODS = /* @__PURE__ */ new Set(["Email", "Whatsapp", "DigitalCertificate"]);
789
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
+ };
790
921
  function validateAssignmentSignerOptions(signer, label = "signer") {
791
922
  if (signer.verification_method !== void 0 && (typeof signer.verification_method !== "string" || !VERIFICATION_METHODS.has(signer.verification_method))) {
792
923
  throw new ValidationError(`${label} has an invalid verification_method`);
793
924
  }
794
- if (signer.notification_methods !== void 0 && (!Array.isArray(signer.notification_methods) || signer.notification_methods.some(
795
- (method) => typeof method !== "string" || !NOTIFICATION_METHODS.has(method)
796
- ))) {
797
- throw new ValidationError(`${label} has invalid notification_methods`);
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
+ }
798
944
  }
799
945
  if (signer.step !== void 0 && (typeof signer.step !== "number" || !Number.isSafeInteger(signer.step) || signer.step < 1)) {
800
946
  throw new ValidationError(`${label} step must be a positive safe integer`);
@@ -1498,7 +1644,6 @@ var FAILED_STATUSES = /* @__PURE__ */ new Set([
1498
1644
  "rejected_by_user",
1499
1645
  "expired"
1500
1646
  ]);
1501
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
1502
1647
  var DocumentResource = class extends BaseResource {
1503
1648
  publicHttp;
1504
1649
  constructor(http, defaultAccountId, logger, publicHttp) {
@@ -1583,7 +1728,8 @@ var DocumentResource = class extends BaseResource {
1583
1728
  * @param params - Filters and pagination: `status`; `method` (`virtual` or
1584
1729
  * `collect`); `tags` (comma-separated IDs, all of which must match);
1585
1730
  * `search` (document name, signer name, or signer email); `sort` (`name` or
1586
- * `updated_at`); `page`; and `per-page` (maximum 100).
1731
+ * `updated_at`); `page`; and `per-page` (the server clamps this to 50
1732
+ * rather than rejecting a larger value).
1587
1733
  * @param accountId - Override the client's default account ID.
1588
1734
  * @returns Matching documents, with pagination in `meta`. Each item:
1589
1735
  * ```jsonc
@@ -2486,7 +2632,7 @@ var DocumentResource = class extends BaseResource {
2486
2632
  const id = this.requireId(documentId, "Document ID");
2487
2633
  assertNonEmptyString(recipient, "recipient");
2488
2634
  if (channel !== void 0) assertNonEmptyString(channel, "channel");
2489
- if (channel === void 0 && !EMAIL_RE.test(recipient)) {
2635
+ if (channel === void 0 && !isEmail(recipient)) {
2490
2636
  throw new ValidationError("recipient must be a valid email address");
2491
2637
  }
2492
2638
  const path2 = `/public/documents/${this.pathSegment(id, "Document ID")}/send-token`;
@@ -2588,9 +2734,6 @@ function normaliseTemplateSigners(signers) {
2588
2734
  throw new ValidationError(`Template signer ${index + 1} requires id`);
2589
2735
  }
2590
2736
  validateAssignmentSignerOptions(signer, `Template signer ${index + 1}`);
2591
- if (signer.notification_methods !== void 0 && signer.notification_methods.length > 1) {
2592
- throw new ValidationError(`Template signer ${index + 1} allows one notification method`);
2593
- }
2594
2737
  const projected = { role_id: signer.role_id, id: signer.id };
2595
2738
  if (signer.verification_method !== void 0) {
2596
2739
  projected.verification_method = signer.verification_method;
@@ -2673,7 +2816,6 @@ function isLegacySendTokenValidation(error) {
2673
2816
  }
2674
2817
 
2675
2818
  // src/resources/signers.ts
2676
- var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2677
2819
  var SignerResource = class extends BaseResource {
2678
2820
  /**
2679
2821
  * Create a signer in the workspace (`POST /accounts/{accountId}/signers`).
@@ -2799,7 +2941,8 @@ var SignerResource = class extends BaseResource {
2799
2941
  * Pagination info (if any) is attached in `meta`.
2800
2942
  *
2801
2943
  * @param params - `page`, `per-page`, and `search` (matches `full_name` or
2802
- * `email`). The API maximum is 100 items per page.
2944
+ * `email`). The server clamps `per-page` to {@link MAX_LIST_PAGE_SIZE}
2945
+ * (50); a larger value is not rejected, it is silently reduced.
2803
2946
  * @param accountId - Override the client's default account ID.
2804
2947
  * @returns The matching signers, with pagination in `meta`. Each item:
2805
2948
  * ```jsonc
@@ -2909,10 +3052,10 @@ var SignerResource = class extends BaseResource {
2909
3052
  * `search` is a substring match across signer fields, so the result is
2910
3053
  * re-filtered here for an exact, case-insensitive email match.
2911
3054
  *
2912
- * Page size is pinned to the API's maximum of 100.
2913
- * An exact address realistically matches one signer, but a search term that
2914
- * matched more than 100 could in principle miss one — the API exposes no
2915
- * exact-email filter to rule that out.
3055
+ * Page size is pinned to {@link MAX_LIST_PAGE_SIZE}, the largest page the
3056
+ * server actually returns. An exact address realistically matches one
3057
+ * signer, but a search term that matched more than that could in principle
3058
+ * miss one — the API exposes no exact-email filter to rule that out.
2916
3059
  *
2917
3060
  * A `404` from the underlying list is treated as "no match" and mapped to
2918
3061
  * `null`; any other {@link ApiError} propagates.
@@ -2942,7 +3085,10 @@ var SignerResource = class extends BaseResource {
2942
3085
  async findByEmail(email, accountId) {
2943
3086
  this.assertEmail(email);
2944
3087
  try {
2945
- const { data } = await this.list({ search: email, "per-page": 100 }, accountId);
3088
+ const { data } = await this.list(
3089
+ { search: email, "per-page": MAX_LIST_PAGE_SIZE },
3090
+ accountId
3091
+ );
2946
3092
  const lower = email.toLowerCase();
2947
3093
  return data.find((s) => (s.email ?? "").toLowerCase() === lower) ?? null;
2948
3094
  } catch (err) {
@@ -2953,7 +3099,7 @@ var SignerResource = class extends BaseResource {
2953
3099
  }
2954
3100
  }
2955
3101
  assertEmail(email) {
2956
- if (!email || !EMAIL_RE2.test(email)) {
3102
+ if (!isEmail(email)) {
2957
3103
  throw new ValidationError("Invalid email address", { email });
2958
3104
  }
2959
3105
  }
@@ -2966,7 +3112,7 @@ function validateCreateSignerPayload(payload) {
2966
3112
  throw new ValidationError("full_name is required");
2967
3113
  }
2968
3114
  const phone = payload.whatsapp_phone_number ?? payload.phone;
2969
- if (payload.email !== void 0 && (typeof payload.email !== "string" || !EMAIL_RE2.test(payload.email))) {
3115
+ if (payload.email !== void 0 && !isEmail(payload.email)) {
2970
3116
  throw new ValidationError("Invalid email address", { email: payload.email });
2971
3117
  }
2972
3118
  validateOptionalPhone(phone);
@@ -2991,7 +3137,7 @@ function validateUpdateSignerPayload(payload) {
2991
3137
  if (payload.full_name !== void 0 && (typeof payload.full_name !== "string" || !payload.full_name.trim())) {
2992
3138
  throw new ValidationError("full_name cannot be empty");
2993
3139
  }
2994
- if (payload.email !== void 0 && (typeof payload.email !== "string" || !EMAIL_RE2.test(payload.email))) {
3140
+ if (payload.email !== void 0 && !isEmail(payload.email)) {
2995
3141
  throw new ValidationError("Invalid email address", { email: payload.email });
2996
3142
  }
2997
3143
  const phone = payload.whatsapp_phone_number ?? payload.phone;
@@ -3007,7 +3153,7 @@ function validateOptionalDigits(value, field) {
3007
3153
  }
3008
3154
  function validateOptionalPhone(value) {
3009
3155
  if (value === void 0) return;
3010
- if (typeof value !== "string" || !/^\+[1-9]\d{1,14}$/u.test(value)) {
3156
+ if (!isE164PhoneNumber(value)) {
3011
3157
  throw new ValidationError("whatsapp_phone_number must use E.164 format");
3012
3158
  }
3013
3159
  }
@@ -3452,7 +3598,6 @@ var DEFAULT_WEBHOOK_EVENTS = Object.freeze([
3452
3598
  "signer_rejected_document",
3453
3599
  "document_processing_failed"
3454
3600
  ]);
3455
- var EMAIL_RE3 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3456
3601
  var WebhookResource = class extends BaseResource {
3457
3602
  /**
3458
3603
  * Register (or replace) the workspace's single webhook subscription
@@ -3505,7 +3650,7 @@ var WebhookResource = class extends BaseResource {
3505
3650
  throw new ValidationError("Webhook subscription payload is required");
3506
3651
  }
3507
3652
  validateWebhookUrl(payload.url);
3508
- if (!payload.email || !EMAIL_RE3.test(payload.email)) {
3653
+ if (!isEmail(payload.email)) {
3509
3654
  throw new ValidationError("Webhook email must be a valid email address", {
3510
3655
  email: payload.email
3511
3656
  });
@@ -4308,7 +4453,6 @@ function validateTagColor(value) {
4308
4453
  }
4309
4454
 
4310
4455
  // src/resources/authentication.ts
4311
- var EMAIL_RE4 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4312
4456
  var AuthenticationResource = class extends BaseResource {
4313
4457
  publicHttp;
4314
4458
  constructor(http, defaultAccountId, logger, publicHttp) {
@@ -4751,10 +4895,685 @@ var AuthenticationResource = class extends BaseResource {
4751
4895
  return url.toString();
4752
4896
  }
4753
4897
  };
4754
- function assertEmail(value) {
4755
- if (typeof value !== "string" || !EMAIL_RE4.test(value)) {
4756
- throw new ValidationError("email must be a valid email address");
4898
+
4899
+ // src/resources/oauth.ts
4900
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
4901
+ var PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
4902
+ var AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server";
4903
+ var CODE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/u;
4904
+ var OAuthResource = class extends BaseResource {
4905
+ publicHttp;
4906
+ constructor(http, defaultAccountId, logger, publicHttp) {
4907
+ super(http, defaultAccountId, logger);
4908
+ this.publicHttp = withoutCredentials(publicHttp ?? http);
4757
4909
  }
4910
+ /**
4911
+ * Read this API's protected-resource metadata
4912
+ * (`GET /.well-known/oauth-protected-resource`).
4913
+ *
4914
+ * Served at the API host root — not under `/v1` — and bare, without the
4915
+ * `{ status, message, data }` envelope, as RFC 8615 requires. Use it to
4916
+ * discover which authorization server may issue tokens for this API and
4917
+ * which scopes it accepts.
4918
+ *
4919
+ * Request body: none. Authentication: none.
4920
+ *
4921
+ * @returns The metadata document:
4922
+ * ```jsonc
4923
+ * {
4924
+ * "resource": "https://api.assinafy.com.br",
4925
+ * "authorization_servers": ["https://auth.assinafy.com.br"],
4926
+ * "scopes_supported": [
4927
+ * "documents:read", "documents:write",
4928
+ * "templates:read", "templates:write",
4929
+ * "account:read", "openid", "profile", "email"
4930
+ * ],
4931
+ * "bearer_methods_supported": ["header"]
4932
+ * }
4933
+ * ```
4934
+ * `offline_access` is deliberately absent: it is a request-time signal to
4935
+ * the authorization server, not a permission this API enforces.
4936
+ * @throws {ApiError} If the host does not publish the document.
4937
+ *
4938
+ * @example
4939
+ * ```ts
4940
+ * const metadata = await client.oauth.getProtectedResourceMetadata();
4941
+ * console.log(metadata.authorization_servers[0]);
4942
+ * ```
4943
+ */
4944
+ async getProtectedResourceMetadata() {
4945
+ return this.call(
4946
+ "Failed to fetch OAuth protected-resource metadata",
4947
+ () => this.publicHttp.get(`${this.apiOrigin()}${PROTECTED_RESOURCE_PATH}`)
4948
+ );
4949
+ }
4950
+ /**
4951
+ * Read the authorization server's metadata
4952
+ * (`GET {issuer}/.well-known/oauth-authorization-server`, RFC 8414).
4953
+ *
4954
+ * Every endpoint URL an OAuth client needs comes from here, so nothing has
4955
+ * to be hardcoded. The document is served by the authorization server, a
4956
+ * different host from this API.
4957
+ *
4958
+ * @param issuer - Issuer to read. Defaults to the first entry of
4959
+ * {@link OAuthResource.getProtectedResourceMetadata}, which costs one extra
4960
+ * request — pass the issuer to skip it.
4961
+ * @returns The metadata document:
4962
+ * ```jsonc
4963
+ * {
4964
+ * "issuer": "https://auth.assinafy.com.br",
4965
+ * "authorization_endpoint": "https://auth.assinafy.com.br/oauth/authorize",
4966
+ * "token_endpoint": "https://api.assinafy.com.br/v1/oauth/token",
4967
+ * "revocation_endpoint": "https://api.assinafy.com.br/v1/oauth/revoke",
4968
+ * "userinfo_endpoint": "https://api.assinafy.com.br/v1/oauth/userinfo",
4969
+ * "jwks_uri": "https://auth.assinafy.com.br/.well-known/jwks.json",
4970
+ * "scopes_supported": ["documents:read", "documents:write", "templates:read",
4971
+ * "templates:write", "account:read", "openid",
4972
+ * "profile", "email", "offline_access"],
4973
+ * "response_types_supported": ["code"],
4974
+ * "grant_types_supported": ["authorization_code", "refresh_token"],
4975
+ * "code_challenge_methods_supported": ["S256"],
4976
+ * "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
4977
+ * "authorization_response_iss_parameter_supported": true,
4978
+ * "client_id_metadata_document_supported": true
4979
+ * }
4980
+ * ```
4981
+ * @throws {ValidationError} If `issuer` is not an absolute `https://` URL,
4982
+ * or the document's own `issuer` disagrees with where it was fetched from
4983
+ * (RFC 8414 §3.3 — a mismatch means the document is not authoritative).
4984
+ * @throws {ApiError} If the authorization server rejects the request.
4985
+ *
4986
+ * @example
4987
+ * ```ts
4988
+ * const as = await client.oauth.getAuthorizationServerMetadata();
4989
+ * console.log(as.authorization_endpoint);
4990
+ * ```
4991
+ */
4992
+ async getAuthorizationServerMetadata(issuer) {
4993
+ const resolved = issuer ?? await this.defaultIssuer();
4994
+ const base = assertHttpsUrl(resolved, "issuer").replace(/\/+$/u, "");
4995
+ const metadata = await this.call(
4996
+ "Failed to fetch OAuth authorization-server metadata",
4997
+ () => this.publicHttp.get(`${base}${AUTHORIZATION_SERVER_PATH}`)
4998
+ );
4999
+ if (normaliseIssuer(metadata?.issuer) !== normaliseIssuer(base)) {
5000
+ throw new ValidationError(
5001
+ "Authorization-server metadata issuer does not match the requested issuer",
5002
+ { expected: base, received: metadata?.issuer ?? null }
5003
+ );
5004
+ }
5005
+ return metadata;
5006
+ }
5007
+ /**
5008
+ * Mint a PKCE pair and a `state`, then build the consent URL to send the
5009
+ * user's browser to (`GET {authorization_endpoint}`).
5010
+ *
5011
+ * Call this once per connection attempt and keep the whole returned object
5012
+ * in the user's session: reusing a verifier or a `state` across attempts
5013
+ * defeats both PKCE and CSRF protection. Navigate the browser to `url` with
5014
+ * a full page load — an `fetch`/XHR cannot show a consent screen.
5015
+ *
5016
+ * PKCE is mandatory for confidential applications too, and Assinafy accepts
5017
+ * only the `S256` challenge method.
5018
+ *
5019
+ * @param options - Authorization-request options.
5020
+ * @param options.clientId - The application's `client_id`.
5021
+ * @param options.redirectUri - One of the application's registered redirect
5022
+ * URIs, matched character for character (`…/callback` and `…/callback/` are
5023
+ * different). Must be `https://` and carry no fragment.
5024
+ * @param options.scopes - Permissions to request, e.g.
5025
+ * `['documents:read', 'documents:write', 'offline_access']`. Ask for the
5026
+ * minimum: the user approves all of them or none. Add `offline_access` to
5027
+ * receive a refresh token and `openid` to receive an `id_token`.
5028
+ * @param options.authorizationEndpoint - Skip discovery by supplying the
5029
+ * endpoint yourself. Defaults to the discovered
5030
+ * `authorization_endpoint`.
5031
+ * @param options.issuer - Issuer to discover from, and the value the
5032
+ * callback's `iss` must equal. Defaults to the discovered issuer.
5033
+ * @param options.resource - RFC 8707 resource indicator. Defaults to this
5034
+ * API's origin; pass `null` to omit it. It must match the value sent to the
5035
+ * token endpoint, or the exchange fails with `invalid_target`.
5036
+ * @param options.state - Supply your own CSRF value instead of a generated
5037
+ * one. Must be unique per attempt.
5038
+ * @param options.codeVerifier - Supply your own RFC 7636 verifier (43–128
5039
+ * characters from `A-Z a-z 0-9 - . _ ~`) instead of a generated one.
5040
+ * @param options.nonce - OIDC nonce echoed in the `id_token`. Generated
5041
+ * automatically when `openid` is requested; pass a string to set it or
5042
+ * `null` to omit it.
5043
+ * @param options.prompt - Forwarded as the OIDC `prompt` parameter, e.g.
5044
+ * `'consent'` to force the approval screen again.
5045
+ * @returns The request to store and redirect with:
5046
+ * ```jsonc
5047
+ * {
5048
+ * "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",
5049
+ * "state": "8Xv2rQ7mJt0aLpKcWn4dZg",
5050
+ * "codeVerifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
5051
+ * "issuer": "https://auth.assinafy.com.br",
5052
+ * "nonce": "n-0S6_WzA2Mj"
5053
+ * }
5054
+ * ```
5055
+ * @throws {ValidationError} If `clientId` is empty, `redirectUri` is not an
5056
+ * absolute `https://` URL without a fragment, `scopes` is empty or contains
5057
+ * a value with whitespace, or a supplied `codeVerifier`/`state` is invalid.
5058
+ * @throws {ApiError} If discovery is needed and fails.
5059
+ *
5060
+ * @example
5061
+ * ```ts
5062
+ * const request = await client.oauth.createAuthorizationUrl({
5063
+ * clientId: process.env.ASSINAFY_CLIENT_ID!,
5064
+ * redirectUri: 'https://myapp.com/oauth/callback',
5065
+ * scopes: ['documents:read', 'documents:write', 'offline_access'],
5066
+ * });
5067
+ * session.oauth = request;
5068
+ * response.redirect(request.url);
5069
+ * ```
5070
+ */
5071
+ async createAuthorizationUrl(options) {
5072
+ assertRecord(options, "authorization options");
5073
+ assertNonEmptyString(options.clientId, "clientId");
5074
+ assertRedirectUri(options.redirectUri);
5075
+ const scope = assertScopes(options.scopes);
5076
+ let endpoint = options.authorizationEndpoint;
5077
+ let issuer = options.issuer;
5078
+ if (endpoint === void 0 || issuer === void 0) {
5079
+ const metadata = await this.getAuthorizationServerMetadata(options.issuer);
5080
+ endpoint ??= metadata.authorization_endpoint;
5081
+ issuer ??= metadata.issuer;
5082
+ }
5083
+ assertHttpsUrl(endpoint, "authorizationEndpoint");
5084
+ assertHttpsUrl(issuer, "issuer");
5085
+ const codeVerifier = options.codeVerifier ?? createCodeVerifier();
5086
+ assertCodeVerifier(codeVerifier);
5087
+ const state = options.state ?? createRandomValue(16);
5088
+ assertNonEmptyString(state, "state");
5089
+ const url = new URL(endpoint);
5090
+ url.searchParams.set("response_type", "code");
5091
+ url.searchParams.set("client_id", options.clientId);
5092
+ url.searchParams.set("redirect_uri", options.redirectUri);
5093
+ url.searchParams.set("scope", scope);
5094
+ url.searchParams.set("state", state);
5095
+ url.searchParams.set("code_challenge", codeChallengeFor(codeVerifier));
5096
+ url.searchParams.set("code_challenge_method", "S256");
5097
+ const { resource } = this.resourceParam(options.resource);
5098
+ if (resource !== void 0) url.searchParams.set("resource", resource);
5099
+ const wantsNonce = options.nonce === void 0 ? options.scopes.includes("openid") : options.nonce !== null;
5100
+ const nonce = wantsNonce ? options.nonce ?? createRandomValue(16) : void 0;
5101
+ if (nonce !== void 0) {
5102
+ assertNonEmptyString(nonce, "nonce");
5103
+ url.searchParams.set("nonce", nonce);
5104
+ }
5105
+ if (options.prompt !== void 0) {
5106
+ assertNonEmptyString(options.prompt, "prompt");
5107
+ url.searchParams.set("prompt", options.prompt);
5108
+ }
5109
+ this.logger.info("Built OAuth authorization URL");
5110
+ const request = {
5111
+ url: url.toString(),
5112
+ state,
5113
+ codeVerifier,
5114
+ issuer
5115
+ };
5116
+ if (nonce !== void 0) request.nonce = nonce;
5117
+ return request;
5118
+ }
5119
+ /**
5120
+ * Validate the authorization response that lands on your redirect URI and
5121
+ * return the code to exchange.
5122
+ *
5123
+ * Checks, in order and before anything else is trusted: `state` equals the
5124
+ * value from {@link OAuthResource.createAuthorizationUrl} (constant-time),
5125
+ * `iss` is present and equals the expected issuer, and only then whether
5126
+ * the server reported an error. A declined consent arrives as
5127
+ * `?error=access_denied`, not as a failed HTTP request.
5128
+ *
5129
+ * The `iss` check is strict because the authorization server advertises
5130
+ * RFC 9207 support and always sends the parameter: a missing `iss` is
5131
+ * treated exactly like a wrong one. Omit `expected.issuer` only if
5132
+ * something between the browser and your handler strips query parameters.
5133
+ *
5134
+ * This performs no network I/O.
5135
+ *
5136
+ * @param params - The callback's query parameters. Accepts an Express-style
5137
+ * `req.query` record, a `URLSearchParams`, a `URL`, a full callback URL
5138
+ * string, or a bare `a=b&c=d` query string.
5139
+ * @param expected - The stored {@link IOAuthAuthorizationRequest} (or any
5140
+ * object carrying its `state` and `issuer`).
5141
+ * @returns The validated response:
5142
+ * ```jsonc
5143
+ * {
5144
+ * "code": "def50200a1b2c3…",
5145
+ * "state": "8Xv2rQ7mJt0aLpKcWn4dZg",
5146
+ * "issuer": "https://auth.assinafy.com.br"
5147
+ * }
5148
+ * ```
5149
+ * @throws {ValidationError} If `state` is missing or does not match, `iss`
5150
+ * is absent or disagrees with the expected issuer, or a successful response
5151
+ * carries no `code`. In every case the response is not yours — stop, do not
5152
+ * exchange.
5153
+ * @throws {OAuthError} If the server returned `error` (e.g.
5154
+ * `access_denied`, `invalid_scope`, `invalid_request`,
5155
+ * `unsupported_response_type`, `invalid_target`).
5156
+ *
5157
+ * @example
5158
+ * ```ts
5159
+ * app.get('/oauth/callback', async (req, res) => {
5160
+ * const stored = req.session.oauth;
5161
+ * const { code } = client.oauth.readAuthorizationCallback(req.query, stored);
5162
+ * const tokens = await client.oauth.exchangeCode({
5163
+ * code,
5164
+ * codeVerifier: stored.codeVerifier,
5165
+ * redirectUri: 'https://myapp.com/oauth/callback',
5166
+ * clientId: process.env.ASSINAFY_CLIENT_ID!,
5167
+ * clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
5168
+ * });
5169
+ * });
5170
+ * ```
5171
+ */
5172
+ readAuthorizationCallback(params, expected) {
5173
+ assertRecord(expected, "expected authorization request");
5174
+ assertNonEmptyString(expected.state, "expected.state");
5175
+ const query = toSearchParams(params);
5176
+ const state = query.get("state");
5177
+ if (state === null || !constantTimeEquals(state, expected.state)) {
5178
+ throw new ValidationError(
5179
+ "OAuth callback state does not match the stored authorization request"
5180
+ );
5181
+ }
5182
+ const issuer = query.get("iss") ?? void 0;
5183
+ if (expected.issuer !== void 0) {
5184
+ if (issuer === void 0 || normaliseIssuer(issuer) !== normaliseIssuer(expected.issuer)) {
5185
+ throw new ValidationError(
5186
+ "OAuth callback issuer is missing or does not match the expected issuer",
5187
+ { expected: expected.issuer, received: issuer ?? null }
5188
+ );
5189
+ }
5190
+ }
5191
+ const error = query.get("error");
5192
+ if (error !== null && error.length > 0) {
5193
+ throw new OAuthError(error, query.get("error_description"), 400, {
5194
+ error,
5195
+ error_description: query.get("error_description")
5196
+ });
5197
+ }
5198
+ const code = query.get("code");
5199
+ if (code === null || code.length === 0) {
5200
+ throw new ValidationError("OAuth callback carries neither a code nor an error");
5201
+ }
5202
+ const result = { code, state };
5203
+ if (issuer !== void 0) result.issuer = issuer;
5204
+ return result;
5205
+ }
5206
+ /**
5207
+ * Exchange an authorization code for tokens
5208
+ * (`POST /oauth/token`, `grant_type=authorization_code`).
5209
+ *
5210
+ * Run this on your server: the code is single-use and expires **60 seconds**
5211
+ * after approval, and a confidential application's secret must never reach
5212
+ * a browser. Every value must match the authorization request exactly, or
5213
+ * the API answers `invalid_grant`.
5214
+ *
5215
+ * @param options - Exchange options.
5216
+ * @param options.code - The code from
5217
+ * {@link OAuthResource.readAuthorizationCallback}.
5218
+ * @param options.codeVerifier - The verifier stored alongside the request.
5219
+ * @param options.redirectUri - The same redirect URI that was authorized.
5220
+ * @param options.clientId - The application's `client_id`.
5221
+ * @param options.clientSecret - The `client_secret`, for confidential
5222
+ * applications only. Public applications omit it and rely on PKCE.
5223
+ * @param options.resource - The same RFC 8707 resource indicator sent to
5224
+ * the authorization endpoint. Defaults to this API's origin; pass `null` to
5225
+ * omit it. A value disagreeing with the authorized one fails with
5226
+ * `invalid_target`.
5227
+ * @returns The token set — a flat object, **not** the API's usual envelope:
5228
+ * ```jsonc
5229
+ * {
5230
+ * "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
5231
+ * "token_type": "Bearer",
5232
+ * "expires_in": 3600,
5233
+ * "scope": "documents:read documents:write",
5234
+ * "refresh_token": "def5020088c2…", // only with offline_access
5235
+ * "id_token": "eyJraWQiOiJEQlR0S0…" // only with openid
5236
+ * }
5237
+ * ```
5238
+ * Read `scope` rather than assuming every requested permission was granted.
5239
+ * @throws {ValidationError} If an argument is missing or malformed, or a
5240
+ * `2xx` response carries no `access_token`.
5241
+ * @throws {OAuthError} `invalid_grant` for a spent, expired, replayed or
5242
+ * mismatched code; `invalid_client` for a bad `client_id`/`client_secret`;
5243
+ * `invalid_target` for a `resource` mismatch.
5244
+ *
5245
+ * @example
5246
+ * ```ts
5247
+ * const tokens = await client.oauth.exchangeCode({
5248
+ * code,
5249
+ * codeVerifier: session.oauth.codeVerifier,
5250
+ * redirectUri: 'https://myapp.com/oauth/callback',
5251
+ * clientId: process.env.ASSINAFY_CLIENT_ID!,
5252
+ * clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
5253
+ * });
5254
+ * ```
5255
+ */
5256
+ async exchangeCode(options) {
5257
+ assertRecord(options, "code exchange options");
5258
+ assertNonEmptyString(options.code, "code");
5259
+ assertCodeVerifier(options.codeVerifier);
5260
+ assertRedirectUri(options.redirectUri);
5261
+ return this.requestToken("Failed to exchange the OAuth authorization code", {
5262
+ grant_type: "authorization_code",
5263
+ code: options.code,
5264
+ redirect_uri: options.redirectUri,
5265
+ code_verifier: options.codeVerifier,
5266
+ ...this.clientAuth(options),
5267
+ ...this.resourceParam(options.resource)
5268
+ });
5269
+ }
5270
+ /**
5271
+ * Renew an access token (`POST /oauth/token`, `grant_type=refresh_token`).
5272
+ *
5273
+ * Access tokens last one hour; refresh tokens are available only when
5274
+ * `offline_access` was requested and granted.
5275
+ *
5276
+ * **Refresh tokens rotate.** Every call returns a new one and retires the
5277
+ * one you sent, and a replayed refresh token cannot be told apart from a
5278
+ * stolen one — so the server ends the entire connection and the user must
5279
+ * reconnect. Therefore: persist `refresh_token` from the response before
5280
+ * doing anything else with it, treat a timeout as "it may have succeeded"
5281
+ * and re-read your stored token instead of retrying blindly, and never run
5282
+ * two refreshes concurrently for one connection.
5283
+ *
5284
+ * Refreshing does not extend the connection's 30-day life.
5285
+ *
5286
+ * @param options - Refresh options.
5287
+ * @param options.refreshToken - The current refresh token.
5288
+ * @param options.clientId - The application's `client_id`.
5289
+ * @param options.clientSecret - The `client_secret`, for confidential
5290
+ * applications only.
5291
+ * @param options.resource - RFC 8707 resource indicator. Defaults to this
5292
+ * API's origin; pass `null` to omit it.
5293
+ * @returns A fresh token set, identical in shape to
5294
+ * {@link OAuthResource.exchangeCode}:
5295
+ * ```jsonc
5296
+ * {
5297
+ * "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
5298
+ * "token_type": "Bearer",
5299
+ * "expires_in": 3600,
5300
+ * "scope": "documents:read documents:write",
5301
+ * "refresh_token": "def50200f1e2…" // NEW — persist it immediately
5302
+ * }
5303
+ * ```
5304
+ * @throws {ValidationError} If an argument is missing, or a `2xx` response
5305
+ * carries no `access_token`.
5306
+ * @throws {OAuthError} `invalid_grant` when the refresh token was already
5307
+ * used, expired, or the user reconnected with different permissions — ask
5308
+ * the user to reconnect. `invalid_client` for bad client credentials.
5309
+ *
5310
+ * @example
5311
+ * ```ts
5312
+ * const tokens = await client.oauth.refreshToken({
5313
+ * refreshToken: connection.refreshToken,
5314
+ * clientId: process.env.ASSINAFY_CLIENT_ID!,
5315
+ * clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
5316
+ * });
5317
+ * await connection.save({ refreshToken: tokens.refresh_token });
5318
+ * ```
5319
+ */
5320
+ async refreshToken(options) {
5321
+ assertRecord(options, "refresh options");
5322
+ assertNonEmptyString(options.refreshToken, "refreshToken");
5323
+ return this.requestToken("Failed to refresh the OAuth access token", {
5324
+ grant_type: "refresh_token",
5325
+ refresh_token: options.refreshToken,
5326
+ ...this.clientAuth(options),
5327
+ ...this.resourceParam(options.resource)
5328
+ });
5329
+ }
5330
+ /**
5331
+ * Revoke an access or refresh token (`POST /oauth/revoke`, RFC 7009).
5332
+ *
5333
+ * Call this when a user disconnects your app, instead of only deleting your
5334
+ * copy of the token. Revoking a refresh token ends the whole connection.
5335
+ *
5336
+ * Every token outcome answers `200` — unknown, malformed and
5337
+ * already-revoked included — so the endpoint cannot be used to probe
5338
+ * whether a token exists. Only failed client authentication returns `401`.
5339
+ *
5340
+ * @param options - Revocation options.
5341
+ * @param options.token - The access or refresh token to revoke.
5342
+ * @param options.clientId - The application's `client_id`.
5343
+ * @param options.clientSecret - The `client_secret`, for confidential
5344
+ * applications only.
5345
+ * @param options.tokenTypeHint - Optional `access_token` or
5346
+ * `refresh_token` hint that lets the server skip a lookup.
5347
+ * @returns Nothing; resolves once the API acknowledges the request.
5348
+ * Request body:
5349
+ * ```jsonc
5350
+ * {
5351
+ * "token": "def50200f1e2…",
5352
+ * "token_type_hint": "refresh_token",
5353
+ * "client_id": "cli_1a2b3c",
5354
+ * "client_secret": "…"
5355
+ * }
5356
+ * ```
5357
+ * @throws {ValidationError} If `token` or `clientId` is missing, or
5358
+ * `tokenTypeHint` is not one of the two documented values.
5359
+ * @throws {OAuthError} `invalid_client` when client authentication fails.
5360
+ *
5361
+ * @example
5362
+ * ```ts
5363
+ * await client.oauth.revokeToken({
5364
+ * token: connection.refreshToken,
5365
+ * tokenTypeHint: 'refresh_token',
5366
+ * clientId: process.env.ASSINAFY_CLIENT_ID!,
5367
+ * clientSecret: process.env.ASSINAFY_CLIENT_SECRET,
5368
+ * });
5369
+ * ```
5370
+ */
5371
+ async revokeToken(options) {
5372
+ assertRecord(options, "revocation options");
5373
+ assertNonEmptyString(options.token, "token");
5374
+ if (options.tokenTypeHint !== void 0 && options.tokenTypeHint !== "access_token" && options.tokenTypeHint !== "refresh_token") {
5375
+ throw new ValidationError("tokenTypeHint must be access_token or refresh_token");
5376
+ }
5377
+ const body = cleanParams({
5378
+ token: options.token,
5379
+ token_type_hint: options.tokenTypeHint,
5380
+ ...this.clientAuth(options)
5381
+ });
5382
+ try {
5383
+ await this.callVoid(
5384
+ "Failed to revoke the OAuth token",
5385
+ () => this.publicHttp.post("/oauth/revoke", body)
5386
+ );
5387
+ } catch (error) {
5388
+ throw OAuthError.upgrade(error);
5389
+ }
5390
+ }
5391
+ /**
5392
+ * Read the OpenID Connect claims of the user who authorized a token
5393
+ * (`GET /oauth/userinfo`).
5394
+ *
5395
+ * Requires the `openid` scope; `name` additionally requires `profile` and
5396
+ * `email`/`email_verified` require `email`. Per OIDC Core §5.3.2 the
5397
+ * response is a flat claims object, not this API's usual envelope.
5398
+ *
5399
+ * @param accessToken - Token to introspect. Omit to use the credential the
5400
+ * client was constructed with (`token` or `apiKey`).
5401
+ * @returns The claims the granted scopes allow:
5402
+ * ```jsonc
5403
+ * {
5404
+ * "sub": "d6zqpbyog2v3xvxerwn8la94",
5405
+ * "name": "Maria Silva",
5406
+ * "email": "maria@example.com",
5407
+ * "email_verified": true
5408
+ * }
5409
+ * ```
5410
+ * `sub` is the stable user identifier; the rest are `null` when their scope
5411
+ * was not granted.
5412
+ * @throws {ValidationError} If `accessToken` is supplied but empty.
5413
+ * @throws {ApiError} `401` when the token is missing, expired or revoked;
5414
+ * `403` when the `openid` scope was not granted — its `WWW-Authenticate`
5415
+ * header names the scope to reconnect with.
5416
+ *
5417
+ * @example
5418
+ * ```ts
5419
+ * const who = await client.oauth.getUserInfo(tokens.access_token);
5420
+ * console.log(who.sub, who.email);
5421
+ * ```
5422
+ */
5423
+ async getUserInfo(accessToken) {
5424
+ if (accessToken === void 0) {
5425
+ return this.call(
5426
+ "Failed to fetch OAuth userinfo",
5427
+ () => this.http.get("/oauth/userinfo")
5428
+ );
5429
+ }
5430
+ assertNonEmptyString(accessToken, "accessToken");
5431
+ return this.call(
5432
+ "Failed to fetch OAuth userinfo",
5433
+ () => this.publicHttp.get("/oauth/userinfo", {
5434
+ headers: { Authorization: `Bearer ${accessToken}` }
5435
+ })
5436
+ );
5437
+ }
5438
+ /** POST the token endpoint and assert the response actually carries a token. */
5439
+ async requestToken(label, body) {
5440
+ let tokens;
5441
+ try {
5442
+ tokens = await this.call(
5443
+ label,
5444
+ () => this.publicHttp.post("/oauth/token", cleanParams(body))
5445
+ );
5446
+ } catch (error) {
5447
+ throw OAuthError.upgrade(error);
5448
+ }
5449
+ if (typeof tokens?.access_token !== "string" || tokens.access_token.length === 0) {
5450
+ throw new ValidationError(`${label}: the token endpoint returned no access_token`, {
5451
+ response: tokens
5452
+ });
5453
+ }
5454
+ return tokens;
5455
+ }
5456
+ /** `client_secret_post` credentials, omitting the secret for public clients. */
5457
+ clientAuth(options) {
5458
+ assertNonEmptyString(options.clientId, "clientId");
5459
+ if (options.clientSecret !== void 0) {
5460
+ assertNonEmptyString(options.clientSecret, "clientSecret");
5461
+ }
5462
+ return { client_id: options.clientId, client_secret: options.clientSecret };
5463
+ }
5464
+ /**
5465
+ * Resolve the optional RFC 8707 `resource` indicator.
5466
+ *
5467
+ * Defaults to the configured API origin, which is what this API publishes
5468
+ * as its `resource`. A loopback `http://` base URL — the shape used by mock
5469
+ * servers and the packed-consumer smoke test — has no valid resource
5470
+ * identifier, so the parameter is simply omitted rather than rejected; an
5471
+ * explicitly supplied value is still required to be `https`.
5472
+ */
5473
+ resourceParam(resource) {
5474
+ if (resource === void 0) {
5475
+ const origin = this.apiOrigin();
5476
+ return origin.startsWith("https:") ? { resource: origin } : {};
5477
+ }
5478
+ if (resource === null) return {};
5479
+ assertHttpsUrl(resource, "resource");
5480
+ return { resource };
5481
+ }
5482
+ /** Discover which authorization server may issue tokens for this API. */
5483
+ async defaultIssuer() {
5484
+ const metadata = await this.getProtectedResourceMetadata();
5485
+ const issuer = metadata?.authorization_servers?.[0];
5486
+ if (typeof issuer !== "string" || issuer.length === 0) {
5487
+ throw new ValidationError(
5488
+ "Protected-resource metadata lists no authorization server",
5489
+ { metadata }
5490
+ );
5491
+ }
5492
+ return issuer;
5493
+ }
5494
+ /**
5495
+ * Origin of the configured API host.
5496
+ *
5497
+ * The `.well-known` document and the RFC 8707 resource indicator both sit
5498
+ * at the host root, while `baseUrl` points at `/v1`.
5499
+ */
5500
+ apiOrigin() {
5501
+ const baseUrl = this.publicHttp.defaults.baseURL;
5502
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) {
5503
+ throw new ValidationError("The client has no base URL to derive the API origin from");
5504
+ }
5505
+ return new URL(baseUrl).origin;
5506
+ }
5507
+ };
5508
+ function createCodeVerifier() {
5509
+ return randomBytes(32).toString("base64url");
5510
+ }
5511
+ function createRandomValue(bytes) {
5512
+ return randomBytes(bytes).toString("base64url");
5513
+ }
5514
+ function codeChallengeFor(codeVerifier) {
5515
+ return createHash("sha256").update(codeVerifier).digest("base64url");
5516
+ }
5517
+ function assertCodeVerifier(value) {
5518
+ if (typeof value !== "string" || !CODE_VERIFIER_PATTERN.test(value)) {
5519
+ throw new ValidationError(
5520
+ "codeVerifier must be 43-128 characters from A-Z a-z 0-9 - . _ ~"
5521
+ );
5522
+ }
5523
+ }
5524
+ function assertRedirectUri(value) {
5525
+ const uri = assertHttpsUrl(value, "redirectUri");
5526
+ if (uri.includes("#")) {
5527
+ throw new ValidationError("redirectUri must not contain a fragment");
5528
+ }
5529
+ }
5530
+ function assertHttpsUrl(value, label) {
5531
+ if (typeof value !== "string" || value.trim().length === 0) {
5532
+ throw new ValidationError(`${label} must be an absolute https URL`);
5533
+ }
5534
+ let url;
5535
+ try {
5536
+ url = new URL(value);
5537
+ } catch {
5538
+ throw new ValidationError(`${label} must be an absolute https URL`);
5539
+ }
5540
+ if (url.protocol !== "https:") {
5541
+ throw new ValidationError(`${label} must be an absolute https URL`);
5542
+ }
5543
+ return value;
5544
+ }
5545
+ function assertScopes(scopes) {
5546
+ if (!Array.isArray(scopes) || scopes.length === 0) {
5547
+ throw new ValidationError("scopes must be a non-empty array of scope strings");
5548
+ }
5549
+ for (const scope of scopes) {
5550
+ if (typeof scope !== "string" || scope.trim().length === 0 || /\s/u.test(scope)) {
5551
+ throw new ValidationError("each scope must be a non-empty string without whitespace");
5552
+ }
5553
+ }
5554
+ return [...new Set(scopes)].join(" ");
5555
+ }
5556
+ function normaliseIssuer(value) {
5557
+ return typeof value === "string" ? value.replace(/\/+$/u, "") : "";
5558
+ }
5559
+ function constantTimeEquals(left, right) {
5560
+ const a = Buffer.from(left, "utf8");
5561
+ const b = Buffer.from(right, "utf8");
5562
+ return a.length === b.length && timingSafeEqual(a, b);
5563
+ }
5564
+ function toSearchParams(params) {
5565
+ if (params instanceof URLSearchParams) return params;
5566
+ if (params instanceof URL) return params.searchParams;
5567
+ if (typeof params === "string") {
5568
+ return params.includes("://") ? new URL(params).searchParams : new URLSearchParams(params.replace(/^\?/u, ""));
5569
+ }
5570
+ assertRecord(params, "callback parameters");
5571
+ const search = new URLSearchParams();
5572
+ for (const [key, value] of Object.entries(params)) {
5573
+ const first = Array.isArray(value) ? value[0] : value;
5574
+ if (typeof first === "string") search.set(key, first);
5575
+ }
5576
+ return search;
4758
5577
  }
4759
5578
 
4760
5579
  // src/resources/fields.ts
@@ -5134,8 +5953,6 @@ function validateSignerAccessCode(value) {
5134
5953
  }
5135
5954
 
5136
5955
  // src/resources/signer-documents.ts
5137
- var EMAIL_RE5 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5138
- var E164_RE = /^\+[1-9]\d{1,14}$/u;
5139
5956
  var SignerDocumentsResource = class extends BaseResource {
5140
5957
  constructor(http, defaultAccountId, logger, publicHttp) {
5141
5958
  super(withoutCredentials(publicHttp ?? http), defaultAccountId, logger);
@@ -5792,10 +6609,10 @@ function validateConfirmDataPayload(payload) {
5792
6609
  throw new ValidationError(`${key} must be a string`);
5793
6610
  }
5794
6611
  }
5795
- if (payload.email !== void 0 && (typeof payload.email !== "string" || !EMAIL_RE5.test(payload.email))) {
6612
+ if (payload.email !== void 0 && !isEmail(payload.email)) {
5796
6613
  throw new ValidationError("email must be a valid email address");
5797
6614
  }
5798
- if (payload.whatsapp_phone_number !== void 0 && (typeof payload.whatsapp_phone_number !== "string" || !E164_RE.test(payload.whatsapp_phone_number))) {
6615
+ if (payload.whatsapp_phone_number !== void 0 && !isE164PhoneNumber(payload.whatsapp_phone_number)) {
5799
6616
  throw new ValidationError("whatsapp_phone_number must use E.164 format");
5800
6617
  }
5801
6618
  if (payload.has_accepted_terms !== void 0 && typeof payload.has_accepted_terms !== "boolean") {
@@ -5982,7 +6799,7 @@ function validateNotificationPreferences(preferences) {
5982
6799
  }
5983
6800
 
5984
6801
  // src/support/webhook-verifier.ts
5985
- import { createHmac, timingSafeEqual } from "crypto";
6802
+ import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
5986
6803
  var WebhookVerifier = class {
5987
6804
  webhookSecret;
5988
6805
  /**
@@ -6024,7 +6841,7 @@ var WebhookVerifier = class {
6024
6841
  const expected = createHmac("sha256", this.webhookSecret).update(buf).digest();
6025
6842
  const actual = Buffer.from(provided, "hex");
6026
6843
  try {
6027
- return timingSafeEqual(expected, actual);
6844
+ return timingSafeEqual2(expected, actual);
6028
6845
  } catch {
6029
6846
  return false;
6030
6847
  }
@@ -6109,6 +6926,7 @@ var AssinafyClient = class _AssinafyClient {
6109
6926
  templates;
6110
6927
  tags;
6111
6928
  auth;
6929
+ oauth;
6112
6930
  fields;
6113
6931
  signerDocuments;
6114
6932
  users;
@@ -6215,6 +7033,12 @@ var AssinafyClient = class _AssinafyClient {
6215
7033
  this.logger,
6216
7034
  this.publicAxiosInstance
6217
7035
  );
7036
+ this.oauth = new OAuthResource(
7037
+ this.axiosInstance,
7038
+ void 0,
7039
+ this.logger,
7040
+ this.publicAxiosInstance
7041
+ );
6218
7042
  this.fields = new FieldsResource(this.axiosInstance, this.defaultAccountId, this.logger);
6219
7043
  this.signerDocuments = new SignerDocumentsResource(
6220
7044
  this.publicAxiosInstance,
@@ -6561,11 +7385,20 @@ function normaliseBaseUrl(raw) {
6561
7385
  if (url.protocol !== "https:" && url.protocol !== "http:") {
6562
7386
  throw new ValidationError("baseUrl must use http or https");
6563
7387
  }
7388
+ if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
7389
+ throw new ValidationError(
7390
+ "baseUrl must use https for a remote host; http is only allowed for localhost"
7391
+ );
7392
+ }
6564
7393
  if (url.username || url.password || raw.includes("?") || raw.includes("#")) {
6565
7394
  throw new ValidationError("baseUrl must not contain credentials, a query, or a fragment");
6566
7395
  }
6567
7396
  return url.href.replace(/\/+$/, "");
6568
7397
  }
7398
+ function isLoopbackHost(hostname) {
7399
+ const host = hostname.replace(/^\[|\]$/gu, "");
7400
+ return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/u.test(host);
7401
+ }
6569
7402
  function installCredentialOriginGuard(http, baseURL) {
6570
7403
  const allowedOrigin = new URL(baseURL).origin;
6571
7404
  http.interceptors.request.use((config) => {
@@ -6639,8 +7472,11 @@ export {
6639
7472
  DEFAULT_WEBHOOK_EVENTS,
6640
7473
  DocumentResource,
6641
7474
  FieldsResource,
7475
+ MAX_LIST_PAGE_SIZE,
6642
7476
  MAX_UPLOAD_BYTES,
6643
7477
  NetworkError,
7478
+ OAuthError,
7479
+ OAuthResource,
6644
7480
  SDK_USER_AGENT,
6645
7481
  SignerDocumentsResource,
6646
7482
  SignerResource,
@@ -6651,5 +7487,6 @@ export {
6651
7487
  WebhookResource,
6652
7488
  WebhookVerifier,
6653
7489
  WorkspaceResource,
6654
- buildAssignmentPayload
7490
+ buildAssignmentPayload,
7491
+ parseWwwAuthenticate
6655
7492
  };