@caido/server-auth 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Caido Labs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ <div align="center">
2
+ <img width="1000" alt="image" src="https://user-images.githubusercontent.com/6225588/211916659-567751d1-0225-402b-9141-4145c18b0834.png">
3
+
4
+ <br />
5
+ <br />
6
+ <a href="https://caido.io/">Website</a>
7
+ <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
8
+ <a href="https://dashboard.caido.io/">Dashboard</a>
9
+ <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
10
+ <a href="https://docs.caido.io/" target="_blank">Docs</a>
11
+ <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
12
+ <a href="https://links.caido.io/roadmap">Roadmap</a>
13
+ <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
14
+ <a href="https://github.com/caido/caido/tree/main/brand">Branding</a>
15
+ <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
16
+ <a href="https://links.caido.io/www-discord" target="_blank">Discord</a>
17
+ <br />
18
+ <hr />
19
+ </div>
20
+
21
+ ## 👋 Server Auth
22
+
23
+ [![NPM Version](https://img.shields.io/npm/v/@caido/server-auth?style=for-the-badge)](https://www.npmjs.com/package/@caido/server-auth)
24
+
25
+ Authenticate with a Caido instance using device code flow.
26
+
27
+ ```typescript
28
+ import { CaidoAuth, BrowserApprover } from "@caido/server-auth";
29
+
30
+ const auth = new CaidoAuth(
31
+ "http://localhost:8080",
32
+ new BrowserApprover((request) => {
33
+ console.log(`Visit ${request.verificationUrl}`);
34
+ console.log(`Enter code: ${request.userCode}`);
35
+ })
36
+ );
37
+
38
+ const token = await auth.startAuthenticationFlow();
39
+ console.log("Access token:", token.accessToken);
40
+ ```
41
+
42
+ ## Examples
43
+
44
+ See the [examples](./examples/) directory for complete working examples:
45
+
46
+ - [Browser Authentication](./examples/browser/) - Manual approval via browser
47
+ - [PAT Authentication](./examples/pat/) - Automated approval using Personal Access Token
48
+
49
+ ## 💚 Community
50
+
51
+ Come join our [Discord](https://links.caido.io/www-discord) community and connect with other Caido users! We'd love to have you as part of the conversation and help with any questions you may have.
package/dist/index.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  let _urql_core = require("@urql/core");
2
+ let graphql = require("graphql");
2
3
  let graphql_ws = require("graphql-ws");
3
4
  let graphql_tag = require("graphql-tag");
4
5
 
@@ -13,51 +14,42 @@ var AuthenticationError = class extends Error {
13
14
  }
14
15
  };
15
16
  /**
16
- * Error thrown when the authentication flow fails to start.
17
+ * Error thrown for errors coming from the Caido cloud API.
18
+ * Used for device approval and device information operations.
17
19
  */
18
- var AuthenticationFlowError = class extends AuthenticationError {
19
- /** Error code from the API */
20
- code;
21
- constructor(code, message) {
22
- super(`${code}: ${message}`);
23
- this.name = "AuthenticationFlowError";
24
- this.code = code;
25
- }
26
- };
27
- /**
28
- * Error thrown when token refresh fails.
29
- */
30
- var TokenRefreshError = class extends AuthenticationError {
31
- /** Error code from the API */
32
- code;
33
- constructor(code, message) {
34
- super(`${code}: ${message}`);
35
- this.name = "TokenRefreshError";
36
- this.code = code;
37
- }
38
- };
39
- /**
40
- * Error thrown when device approval fails.
41
- */
42
- var DeviceApprovalError = class extends AuthenticationError {
20
+ var CloudError = class extends AuthenticationError {
43
21
  /** HTTP status code if available */
44
22
  statusCode;
45
- constructor(message, statusCode) {
23
+ /** Error code from the API if available */
24
+ code;
25
+ /** Reason for the error if available */
26
+ reason;
27
+ constructor(message, options) {
46
28
  super(message);
47
- this.name = "DeviceApprovalError";
48
- this.statusCode = statusCode;
29
+ this.name = "CloudError";
30
+ this.statusCode = options?.statusCode;
31
+ this.code = options?.code;
32
+ this.reason = options?.reason;
49
33
  }
50
34
  };
51
35
  /**
52
- * Error thrown when fetching device information fails.
36
+ * Error thrown for errors coming from the Caido instance.
37
+ * Used for authentication flow and token refresh operations.
53
38
  */
54
- var DeviceInformationError = class extends AuthenticationError {
55
- /** HTTP status code if available */
56
- statusCode;
57
- constructor(message, statusCode) {
39
+ var InstanceError = class extends AuthenticationError {
40
+ /** Error code from the API */
41
+ code;
42
+ /** Reason for the error if available */
43
+ reason;
44
+ /** Error message if available */
45
+ errorMessage;
46
+ constructor(code, options) {
47
+ const message = options?.reason ?? options?.message ?? code;
58
48
  super(message);
59
- this.name = "DeviceInformationError";
60
- this.statusCode = statusCode;
49
+ this.name = "InstanceError";
50
+ this.code = code;
51
+ this.reason = options?.reason;
52
+ this.errorMessage = options?.message;
61
53
  }
62
54
  };
63
55
 
@@ -73,8 +65,21 @@ const START_AUTHENTICATION_FLOW = graphql_tag.gql`
73
65
  expiresAt
74
66
  }
75
67
  error {
76
- code
77
- message
68
+ ... on AuthenticationUserError {
69
+ code
70
+ reason
71
+ }
72
+ ... on CloudUserError {
73
+ code
74
+ reason
75
+ }
76
+ ... on InternalUserError {
77
+ code
78
+ message
79
+ }
80
+ ... on OtherUserError {
81
+ code
82
+ }
78
83
  }
79
84
  }
80
85
  }
@@ -84,27 +89,51 @@ const CREATED_AUTHENTICATION_TOKEN = graphql_tag.gql`
84
89
  createdAuthenticationToken(requestId: $requestId) {
85
90
  token {
86
91
  accessToken
87
- refreshToken
88
92
  expiresAt
93
+ refreshToken
94
+ scopes
89
95
  }
90
96
  error {
91
- code
92
- message
97
+ ... on AuthenticationUserError {
98
+ code
99
+ reason
100
+ }
101
+ ... on InternalUserError {
102
+ code
103
+ message
104
+ }
105
+ ... on OtherUserError {
106
+ code
107
+ }
93
108
  }
94
109
  }
95
110
  }
96
111
  `;
97
112
  const REFRESH_AUTHENTICATION_TOKEN = graphql_tag.gql`
98
- mutation RefreshAuthenticationToken($refreshToken: String!) {
113
+ mutation RefreshAuthenticationToken($refreshToken: Token!) {
99
114
  refreshAuthenticationToken(refreshToken: $refreshToken) {
100
115
  token {
101
116
  accessToken
102
- refreshToken
103
117
  expiresAt
118
+ refreshToken
119
+ scopes
104
120
  }
105
121
  error {
106
- code
107
- message
122
+ ... on AuthenticationUserError {
123
+ code
124
+ reason
125
+ }
126
+ ... on CloudUserError {
127
+ code
128
+ reason
129
+ }
130
+ ... on InternalUserError {
131
+ code
132
+ message
133
+ }
134
+ ... on OtherUserError {
135
+ code
136
+ }
108
137
  }
109
138
  }
110
139
  }
@@ -117,48 +146,54 @@ const REFRESH_AUTHENTICATION_TOKEN = graphql_tag.gql`
117
146
  *
118
147
  * @example
119
148
  * ```typescript
120
- * import { CaidoAuth, BrowserApprover } from "@caido/auth";
149
+ * import { AuthClient, BrowserApprover } from "@caido/auth";
121
150
  *
122
- * const auth = new CaidoAuth(
123
- * "http://localhost:8080",
124
- * new BrowserApprover((request) => {
151
+ * const auth = new AuthClient({
152
+ * instanceUrl: "http://localhost:8080",
153
+ * approver: new BrowserApprover((request) => {
125
154
  * console.log(`Visit ${request.verificationUrl}`);
126
155
  * })
127
- * );
156
+ * });
128
157
  *
129
158
  * const token = await auth.startAuthenticationFlow();
130
159
  * console.log("Access token:", token.accessToken);
131
160
  * ```
132
161
  */
133
- var CaidoAuth = class {
162
+ var AuthClient = class {
134
163
  instanceUrl;
135
164
  graphqlUrl;
136
165
  websocketUrl;
137
166
  approver;
138
167
  client;
139
- /**
140
- * Create a new CaidoAuth client.
141
- *
142
- * @param instanceUrl - Base URL of the Caido instance (e.g., "http://localhost:8080")
143
- * @param approver - The approver to use for the authentication flow
144
- */
145
- constructor(instanceUrl, approver) {
146
- this.instanceUrl = instanceUrl.replace(/\/$/, "");
168
+ fetchFn;
169
+ timeout;
170
+ constructor(options) {
171
+ this.instanceUrl = options.instanceUrl.replace(/\/$/, "");
147
172
  this.graphqlUrl = `${this.instanceUrl}/graphql`;
148
173
  this.websocketUrl = this.getWebsocketUrl();
149
- this.approver = approver;
174
+ this.approver = options.approver;
175
+ this.fetchFn = options.fetch;
176
+ this.timeout = options.timeout;
150
177
  this.client = new _urql_core.Client({
151
178
  url: this.graphqlUrl,
152
- exchanges: [_urql_core.fetchExchange]
179
+ exchanges: [_urql_core.fetchExchange],
180
+ fetchOptions: () => {
181
+ const fetchOptions = {};
182
+ if (this.timeout !== void 0) fetchOptions.signal = AbortSignal.timeout(this.timeout);
183
+ return fetchOptions;
184
+ },
185
+ fetch: this.fetchFn
153
186
  });
154
187
  }
155
- /**
156
- * Convert HTTP(S) URL to WS(S) URL for subscriptions.
157
- */
158
188
  getWebsocketUrl() {
159
189
  const url = new URL(this.graphqlUrl);
160
190
  return `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/ws/graphql`;
161
191
  }
192
+ extractErrorDetails(error) {
193
+ if ("reason" in error) return { reason: error.reason };
194
+ if ("message" in error) return { message: error.message };
195
+ return {};
196
+ }
162
197
  /**
163
198
  * Start the device code authentication flow.
164
199
  *
@@ -169,16 +204,19 @@ var CaidoAuth = class {
169
204
  * 4. Returns the authentication token once approved
170
205
  *
171
206
  * @returns The authentication token
172
- * @throws {AuthenticationFlowError} If the flow fails to start
207
+ * @throws {InstanceError} If the flow fails to start
173
208
  * @throws {AuthenticationError} If token retrieval fails
174
209
  */
175
210
  async startAuthenticationFlow() {
176
211
  const result = await this.client.mutation(START_AUTHENTICATION_FLOW, {}).toPromise();
177
- if (result.error) throw new AuthenticationFlowError("GRAPHQL_ERROR", result.error.message);
212
+ if (result.error) throw new InstanceError("GRAPHQL_ERROR", { message: result.error.message });
178
213
  const payload = result.data?.startAuthenticationFlow;
179
- if (!payload) throw new AuthenticationFlowError("NO_RESPONSE", "No response from startAuthenticationFlow");
180
- if (payload.error) throw new AuthenticationFlowError(payload.error.code, payload.error.message);
181
- if (!payload.request) throw new AuthenticationFlowError("NO_REQUEST", "No authentication request returned");
214
+ if (!payload) throw new InstanceError("NO_RESPONSE", { message: "No response from startAuthenticationFlow" });
215
+ if (payload.error) {
216
+ const details = this.extractErrorDetails(payload.error);
217
+ throw new InstanceError(payload.error.code, details);
218
+ }
219
+ if (!payload.request) throw new InstanceError("NO_REQUEST", { message: "No authentication request returned" });
182
220
  const authRequest = {
183
221
  id: payload.request.id,
184
222
  userCode: payload.request.userCode,
@@ -188,23 +226,11 @@ var CaidoAuth = class {
188
226
  await this.approver.approve(authRequest);
189
227
  return await this.waitForToken(authRequest.id);
190
228
  }
191
- /**
192
- * Subscribe and wait for the authentication token.
193
- *
194
- * @param requestId - The authentication request ID
195
- * @returns The authentication token once the user authorizes
196
- * @throws {AuthenticationError} If subscription fails or returns an error
197
- */
198
229
  async waitForToken(requestId) {
199
230
  return new Promise((resolve, reject) => {
200
231
  const wsClient = (0, graphql_ws.createClient)({ url: this.websocketUrl });
201
232
  const unsubscribe = wsClient.subscribe({
202
- query: CREATED_AUTHENTICATION_TOKEN.loc?.source.body ?? `subscription CreatedAuthenticationToken($requestId: ID!) {
203
- createdAuthenticationToken(requestId: $requestId) {
204
- token { accessToken refreshToken expiresAt }
205
- error { code message }
206
- }
207
- }`,
233
+ query: (0, graphql.print)(CREATED_AUTHENTICATION_TOKEN),
208
234
  variables: { requestId }
209
235
  }, {
210
236
  next: (result) => {
@@ -212,7 +238,8 @@ var CaidoAuth = class {
212
238
  if (payload?.error) {
213
239
  unsubscribe();
214
240
  wsClient.dispose();
215
- reject(new AuthenticationError(`${payload.error.code}: ${payload.error.message}`));
241
+ const details = this.extractErrorDetails(payload.error);
242
+ reject(new InstanceError(payload.error.code, details));
216
243
  return;
217
244
  }
218
245
  if (payload?.token) {
@@ -221,17 +248,18 @@ var CaidoAuth = class {
221
248
  resolve({
222
249
  accessToken: payload.token.accessToken,
223
250
  refreshToken: payload.token.refreshToken,
224
- expiresAt: new Date(payload.token.expiresAt)
251
+ expiresAt: new Date(payload.token.expiresAt),
252
+ scopes: payload.token.scopes
225
253
  });
226
254
  }
227
255
  },
228
256
  error: (error) => {
229
257
  wsClient.dispose();
230
- reject(new AuthenticationError(error instanceof Error ? error.message : String(error)));
258
+ reject(new InstanceError("SUBSCRIPTION_ERROR", { message: error instanceof Error ? error.message : String(error) }));
231
259
  },
232
260
  complete: () => {
233
261
  wsClient.dispose();
234
- reject(new AuthenticationError("Subscription ended without receiving token"));
262
+ reject(new InstanceError("SUBSCRIPTION_COMPLETE", { message: "Subscription ended without receiving token" }));
235
263
  }
236
264
  });
237
265
  });
@@ -241,19 +269,23 @@ var CaidoAuth = class {
241
269
  *
242
270
  * @param refreshToken - The refresh token from a previous authentication
243
271
  * @returns New authentication token with updated access and refresh tokens
244
- * @throws {TokenRefreshError} If the refresh fails
272
+ * @throws {InstanceError} If the refresh fails
245
273
  */
246
274
  async refreshToken(refreshToken) {
247
275
  const result = await this.client.mutation(REFRESH_AUTHENTICATION_TOKEN, { refreshToken }).toPromise();
248
- if (result.error) throw new TokenRefreshError("GRAPHQL_ERROR", result.error.message);
276
+ if (result.error) throw new InstanceError("GRAPHQL_ERROR", { message: result.error.message });
249
277
  const payload = result.data?.refreshAuthenticationToken;
250
- if (!payload) throw new TokenRefreshError("NO_RESPONSE", "No response from refreshAuthenticationToken");
251
- if (payload.error) throw new TokenRefreshError(payload.error.code, payload.error.message);
252
- if (!payload.token) throw new TokenRefreshError("NO_TOKEN", "No token returned from refresh");
278
+ if (!payload) throw new InstanceError("NO_RESPONSE", { message: "No response from refreshAuthenticationToken" });
279
+ if (payload.error) {
280
+ const details = this.extractErrorDetails(payload.error);
281
+ throw new InstanceError(payload.error.code, details);
282
+ }
283
+ if (!payload.token) throw new InstanceError("NO_TOKEN", { message: "No token returned from refresh" });
253
284
  return {
254
285
  accessToken: payload.token.accessToken,
255
286
  refreshToken: payload.token.refreshToken,
256
- expiresAt: new Date(payload.token.expiresAt)
287
+ expiresAt: new Date(payload.token.expiresAt),
288
+ scopes: payload.token.scopes
257
289
  };
258
290
  }
259
291
  };
@@ -316,15 +348,14 @@ var PATApprover = class {
316
348
  pat;
317
349
  allowedScopes;
318
350
  apiUrl;
319
- /**
320
- * Create a new PATApprover.
321
- *
322
- * @param options - Configuration options for the approver
323
- */
351
+ fetchFn;
352
+ timeout;
324
353
  constructor(options) {
325
354
  this.pat = options.pat;
326
355
  this.allowedScopes = options.allowedScopes;
327
356
  this.apiUrl = (options.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
357
+ this.fetchFn = options.fetch ?? globalThis.fetch;
358
+ this.timeout = options.timeout;
328
359
  }
329
360
  /**
330
361
  * Approve the authentication request using the PAT.
@@ -333,50 +364,71 @@ var PATApprover = class {
333
364
  * and finally approves the device.
334
365
  *
335
366
  * @param request - The authentication request
336
- * @throws {DeviceInformationError} If fetching device information fails
337
- * @throws {DeviceApprovalError} If approving the device fails
367
+ * @throws {CloudError} If fetching device information or approving the device fails
338
368
  */
339
369
  async approve(request) {
340
370
  let scopesToApprove = (await this.getDeviceInformation(request.userCode)).scopes.map((s) => s.name);
341
371
  if (this.allowedScopes) scopesToApprove = scopesToApprove.filter((scope) => this.allowedScopes.includes(scope));
342
372
  await this.approveDevice(request.userCode, scopesToApprove);
343
373
  }
344
- /**
345
- * Fetch device information from the API.
346
- *
347
- * @param userCode - The user code from the authentication request
348
- * @returns The device information including available scopes
349
- * @throws {DeviceInformationError} If the request fails
350
- */
374
+ async sendRequest(url, options) {
375
+ const fetchOptions = {
376
+ method: options.method,
377
+ headers: options.headers
378
+ };
379
+ if (this.timeout !== void 0) fetchOptions.signal = AbortSignal.timeout(this.timeout);
380
+ return this.fetchFn(url, fetchOptions);
381
+ }
382
+ async parseOAuth2Error(response) {
383
+ let errorText;
384
+ let errorCode;
385
+ let errorDescription;
386
+ try {
387
+ const errorData = await response.json();
388
+ if (typeof errorData === "string") errorText = errorData;
389
+ else {
390
+ errorCode = errorData.error;
391
+ errorDescription = errorData.error_description;
392
+ errorText = errorDescription ?? errorCode ?? "Unknown error";
393
+ }
394
+ } catch {
395
+ errorText = await response.text().catch(() => "Unknown error");
396
+ }
397
+ return {
398
+ errorText,
399
+ errorCode,
400
+ errorDescription
401
+ };
402
+ }
351
403
  async getDeviceInformation(userCode) {
352
404
  const params = new URLSearchParams();
353
405
  params.append("user_code", userCode);
354
406
  const url = new URL(`${this.apiUrl}/oauth2/device/information`);
355
407
  url.search = params.toString();
356
- const response = await fetch(url, {
408
+ const response = await this.sendRequest(url, {
357
409
  method: "GET",
358
410
  headers: {
359
411
  Authorization: `Bearer ${this.pat}`,
360
412
  Accept: "application/json"
361
413
  }
362
414
  });
363
- if (!response.ok) throw new DeviceInformationError(`Failed to get device information: ${await response.text().catch(() => "Unknown error")}`, response.status);
415
+ if (!response.ok) {
416
+ const { errorText, errorCode, errorDescription } = await this.parseOAuth2Error(response);
417
+ throw new CloudError(`Failed to get device information: ${errorText}`, {
418
+ statusCode: response.status,
419
+ code: errorCode,
420
+ reason: errorDescription
421
+ });
422
+ }
364
423
  return await response.json();
365
424
  }
366
- /**
367
- * Approve the device with the specified scopes.
368
- *
369
- * @param userCode - The user code from the authentication request
370
- * @param scopes - The scopes to approve
371
- * @throws {DeviceApprovalError} If the request fails
372
- */
373
425
  async approveDevice(userCode, scopes) {
374
426
  const params = new URLSearchParams();
375
427
  params.append("user_code", userCode);
376
428
  params.append("scope", scopes.join(","));
377
429
  const url = new URL(`${this.apiUrl}/oauth2/device/approve`);
378
430
  url.search = params.toString();
379
- const response = await fetch(url, {
431
+ const response = await this.sendRequest(url, {
380
432
  method: "POST",
381
433
  headers: {
382
434
  Authorization: `Bearer ${this.pat}`,
@@ -384,17 +436,22 @@ var PATApprover = class {
384
436
  Accept: "application/json"
385
437
  }
386
438
  });
387
- if (!response.ok) throw new DeviceApprovalError(`Failed to approve device: ${await response.text().catch(() => "Unknown error")}`, response.status);
439
+ if (!response.ok) {
440
+ const { errorText, errorCode, errorDescription } = await this.parseOAuth2Error(response);
441
+ throw new CloudError(`Failed to approve device: ${errorText}`, {
442
+ statusCode: response.status,
443
+ code: errorCode,
444
+ reason: errorDescription
445
+ });
446
+ }
388
447
  }
389
448
  };
390
449
 
391
450
  //#endregion
451
+ exports.AuthClient = AuthClient;
392
452
  exports.AuthenticationError = AuthenticationError;
393
- exports.AuthenticationFlowError = AuthenticationFlowError;
394
453
  exports.BrowserApprover = BrowserApprover;
395
- exports.CaidoAuth = CaidoAuth;
396
- exports.DeviceApprovalError = DeviceApprovalError;
397
- exports.DeviceInformationError = DeviceInformationError;
454
+ exports.CloudError = CloudError;
455
+ exports.InstanceError = InstanceError;
398
456
  exports.PATApprover = PATApprover;
399
- exports.TokenRefreshError = TokenRefreshError;
400
457
  //# sourceMappingURL=index.cjs.map