docpouch-client 0.9.1 → 1.0.1

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.js CHANGED
@@ -25,6 +25,14 @@ export default class docPouchClient {
25
25
  * @type {string | null}
26
26
  */
27
27
  this.authToken = null;
28
+ this.oidcConfig = null;
29
+ this.oidcAccessToken = null;
30
+ this.oidcRefreshToken = null;
31
+ this.oidcIdToken = null;
32
+ this.oidcTokenExpiry = 0;
33
+ this.codeVerifier = null;
34
+ this.oidcState = null;
35
+ this.authMethod = 'none';
28
36
  /**
29
37
  * Flag indicating whether a connection attempt is in progress.
30
38
  *
@@ -66,7 +74,7 @@ export default class docPouchClient {
66
74
  return;
67
75
  }
68
76
  this.realTimeSync = newRealTimeSync;
69
- if (newRealTimeSync && this.authToken) {
77
+ if (newRealTimeSync && (this.authToken || this.oidcAccessToken)) {
70
78
  console.log("Activating realtime updates");
71
79
  // Ensure we're not in the middle of another connection attempt
72
80
  if (this.connectionInProgress) {
@@ -96,6 +104,7 @@ export default class docPouchClient {
96
104
  const response = await this.request('/users/login', 'POST', credentials, false);
97
105
  if (response.token) {
98
106
  this.authToken = response.token;
107
+ this.authMethod = 'jwt';
99
108
  // Reconnect websocket with new token if realtime sync is enabled
100
109
  if (this.realTimeSync) {
101
110
  this.initWebSocket();
@@ -236,8 +245,11 @@ export default class docPouchClient {
236
245
  */
237
246
  setToken(token) {
238
247
  console.log("Setting token to:", token ? "***token***" : "null");
248
+ this.authMethod = token ? 'jwt' : 'none';
239
249
  const tokenChanged = this.authToken !== token;
240
250
  this.authToken = token;
251
+ if (!token)
252
+ this.clearOidcTokens();
241
253
  if (!tokenChanged) {
242
254
  console.log("Token unchanged, no need to reconnect");
243
255
  return;
@@ -279,17 +291,208 @@ export default class docPouchClient {
279
291
  console.log("Socket connection debug info:");
280
292
  console.log("- Connected:", this.socket.connected);
281
293
  console.log("- Socket ID:", this.socket.id);
282
- console.log("- Auth token present:", !!this.authToken);
294
+ console.log("- Auth token present:", !!this.authToken, "(method:", this.authMethod + ")");
295
+ console.log("- OIDC access token present:", !!this.oidcAccessToken);
283
296
  console.log("- Connection in progress:", this.connectionInProgress);
284
297
  console.log("- Realtime sync enabled:", this.realTimeSync);
285
298
  console.log("- Socket options:", this.socket.io.opts);
299
+ const activeToken = this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
286
300
  // Try to force reconnection
287
- if (!this.socket.connected && this.authToken && this.realTimeSync) {
301
+ if (!this.socket.connected && activeToken && this.realTimeSync) {
288
302
  console.log("Attempting to force reconnection...");
289
- this.socket.auth = { token: this.authToken };
303
+ this.socket.auth = { token: activeToken };
290
304
  this.socket.connect();
291
305
  }
292
306
  }
307
+ // OIDC Authentication Methods
308
+ /**
309
+ * Initiates the OIDC authentication flow by redirecting to the authorization endpoint.
310
+ *
311
+ * @param {I_OidcConfig} config - OIDC provider configuration.
312
+ * @returns {Promise<void>}
313
+ */
314
+ async loginWithOidc(config) {
315
+ this.oidcConfig = config;
316
+ const response = await fetch(`${config.issuer}/.well-known/openid-configuration`);
317
+ const discovery = await response.json();
318
+ this.codeVerifier = this.generateCodeVerifier();
319
+ this.oidcState = this.generateState();
320
+ const codeChallenge = await this.generateCodeChallenge(this.codeVerifier);
321
+ const params = new URLSearchParams({
322
+ response_type: 'code',
323
+ client_id: config.clientId,
324
+ redirect_uri: config.redirectUri,
325
+ scope: config.scopes?.join(' ') || 'openid profile',
326
+ state: this.oidcState,
327
+ code_challenge: codeChallenge,
328
+ code_challenge_method: 'S256'
329
+ });
330
+ window.location.href = `${discovery.authorization_endpoint}?${params.toString()}`;
331
+ }
332
+ /**
333
+ * Handles the OIDC callback by exchanging the authorization code for tokens.
334
+ *
335
+ * @returns {Promise<boolean>} True if the callback was handled successfully.
336
+ */
337
+ async handleOidcCallback() {
338
+ const params = new URLSearchParams(window.location.search);
339
+ const code = params.get('code');
340
+ const state = params.get('state');
341
+ const error = params.get('error');
342
+ if (error)
343
+ throw new Error(`OAuth error: ${error}`);
344
+ if (!code || !state)
345
+ return false;
346
+ if (state !== this.oidcState)
347
+ throw new Error('State mismatch');
348
+ const discovery = await this.discoverOidc();
349
+ const body = new URLSearchParams({
350
+ grant_type: 'authorization_code',
351
+ code,
352
+ redirect_uri: this.oidcConfig.redirectUri,
353
+ client_id: this.oidcConfig.clientId,
354
+ code_verifier: this.codeVerifier || ''
355
+ });
356
+ const response = await fetch(discovery.token_endpoint, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
359
+ body: body.toString()
360
+ });
361
+ if (!response.ok)
362
+ throw new Error('Token exchange failed');
363
+ const raw = await response.json();
364
+ const tokens = {
365
+ accessToken: raw.access_token,
366
+ refreshToken: raw.refresh_token,
367
+ idToken: raw.id_token,
368
+ expiresIn: raw.expires_in,
369
+ tokenType: raw.token_type,
370
+ scope: raw.scope
371
+ };
372
+ this.setOidcTokens(tokens);
373
+ if (this.realTimeSync)
374
+ this.initWebSocket();
375
+ return true;
376
+ }
377
+ restoreOidcSession() {
378
+ if (typeof window === 'undefined' || !window.localStorage)
379
+ return false;
380
+ const stored = localStorage.getItem('docpouch_oidc_session');
381
+ if (!stored)
382
+ return false;
383
+ try {
384
+ const session = JSON.parse(stored);
385
+ if (!session.accessToken || Date.now() >= session.expiry) {
386
+ this.clearPersistedOidcSession();
387
+ return false;
388
+ }
389
+ this.authMethod = 'oidc';
390
+ this.oidcAccessToken = session.accessToken;
391
+ this.oidcRefreshToken = session.refreshToken || null;
392
+ this.oidcIdToken = session.idToken || null;
393
+ this.oidcTokenExpiry = session.expiry;
394
+ return true;
395
+ }
396
+ catch {
397
+ this.clearPersistedOidcSession();
398
+ return false;
399
+ }
400
+ }
401
+ /**
402
+ * Ensures the OIDC access token is valid, refreshing it if necessary.
403
+ */
404
+ async ensureValidOidcToken() {
405
+ if (this.authMethod !== 'oidc' || !this.oidcAccessToken)
406
+ throw new Error('Not authenticated via OIDC');
407
+ if (Date.now() >= this.oidcTokenExpiry - 60000) {
408
+ await this.refreshOidcToken();
409
+ }
410
+ return this.oidcAccessToken;
411
+ }
412
+ /**
413
+ * Returns the current authentication method.
414
+ *
415
+ * @returns {'jwt' | 'oidc' | 'none'} The active auth method.
416
+ */
417
+ getAuthMethod() {
418
+ return this.authMethod;
419
+ }
420
+ /**
421
+ * Checks whether the client is currently authenticated.
422
+ *
423
+ * @returns {boolean} True if authenticated.
424
+ */
425
+ isAuthenticated() {
426
+ return this.authMethod !== 'none' && (this.authMethod === 'jwt'
427
+ ? !!this.authToken
428
+ : (this.oidcAccessToken !== null && Date.now() < this.oidcTokenExpiry));
429
+ }
430
+ /**
431
+ * Returns the current active token, regardless of auth method.
432
+ *
433
+ * @returns {string | null} The active token or null.
434
+ */
435
+ getToken() {
436
+ return this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
437
+ }
438
+ /**
439
+ * Logs out the client, clearing all tokens and disconnecting WebSocket.
440
+ *
441
+ * @returns {Promise<void>}
442
+ */
443
+ async logout() {
444
+ this.authToken = null;
445
+ this.clearOidcTokens();
446
+ this.authMethod = 'none';
447
+ if (this.socket.connected)
448
+ this.socket.disconnect();
449
+ if (typeof window !== 'undefined' && window.location &&
450
+ (window.location.search.includes('code=') || window.location.search.includes('state='))) {
451
+ window.history.replaceState({}, '', window.location.pathname);
452
+ }
453
+ }
454
+ // OIDC Dynamic Client Registration Methods
455
+ /**
456
+ * Registers a new OIDC client with the server (dynamic client registration).
457
+ *
458
+ * @param {I_OidcClientRegistration} registration - Client metadata for registration.
459
+ * @param {string} [registrationToken] - Initial registration access token. If not provided, the current auth token is used.
460
+ * @returns {Promise<I_OidcClientResponse>} The registered client details.
461
+ */
462
+ async registerOidcClient(registration, registrationToken) {
463
+ return await this.request('/oidc/reg', 'POST', registration, true, registrationToken);
464
+ }
465
+ /**
466
+ * Retrieves the registration state of an existing OIDC client.
467
+ *
468
+ * @param {string} clientId - The client identifier.
469
+ * @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
470
+ * @returns {Promise<I_OidcClientResponse>} The client details.
471
+ */
472
+ async getOidcClient(clientId, registrationToken) {
473
+ return await this.request(`/oidc/reg/${clientId}`, 'GET', undefined, true, registrationToken);
474
+ }
475
+ /**
476
+ * Updates the registration of an existing OIDC client.
477
+ *
478
+ * @param {string} clientId - The client identifier.
479
+ * @param {I_OidcClientRegistration} registration - Updated client metadata.
480
+ * @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
481
+ * @returns {Promise<I_OidcClientResponse>} The updated client details.
482
+ */
483
+ async updateOidcClient(clientId, registration, registrationToken) {
484
+ return await this.request(`/oidc/reg/${clientId}`, 'PUT', registration, true, registrationToken);
485
+ }
486
+ /**
487
+ * Deletes an OIDC client registration.
488
+ *
489
+ * @param {string} clientId - The client identifier.
490
+ * @param {string} [registrationToken] - The registration access token. If not provided, the current auth token is used.
491
+ * @returns {Promise<void>}
492
+ */
493
+ async deleteOidcClient(clientId, registrationToken) {
494
+ await this.request(`/oidc/reg/${clientId}`, 'DELETE', undefined, true, registrationToken);
495
+ }
293
496
  /**
294
497
  * Sets up permanent socket listeners for the client.
295
498
  *
@@ -320,8 +523,9 @@ export default class docPouchClient {
320
523
  * @private
321
524
  */
322
525
  initWebSocket() {
323
- console.log("initWebSocket called. Auth token present:", !!this.authToken, "Connection in progress:", this.connectionInProgress, "Socket connected:", this.socket.connected);
324
- if (!this.authToken) {
526
+ const token = this.authMethod === 'oidc' ? this.oidcAccessToken : this.authToken;
527
+ console.log("initWebSocket called. Auth token present:", !!this.authToken, "Connection in progress:", this.connectionInProgress, "Socket connected:", this.socket.connected, "Auth method:", this.authMethod);
528
+ if (!token) {
325
529
  console.log("Skipping WebSocket initialization: No auth token");
326
530
  return;
327
531
  }
@@ -337,7 +541,7 @@ export default class docPouchClient {
337
541
  try {
338
542
  console.log("Setting up WebSocket connection with token");
339
543
  // Update the auth token
340
- this.socket.auth = { token: this.authToken };
544
+ this.socket.auth = { token };
341
545
  // Remove any dynamic event listeners that might have been added
342
546
  this.socket.offAny();
343
547
  // Set up event handler for application events
@@ -383,12 +587,15 @@ export default class docPouchClient {
383
587
  * @returns {Promise<T>} Parsed JSON response body.
384
588
  * @private
385
589
  */
386
- async request(endpoint, method, body, requiresAuth = true) {
590
+ async request(endpoint, method, body, requiresAuth = true, authTokenOverride) {
387
591
  const headers = {
388
592
  'Content-Type': 'application/json',
389
593
  };
390
- if (requiresAuth && this.authToken)
391
- headers['Authorization'] = `Bearer ${this.authToken}`;
594
+ const authToken = authTokenOverride ?? (this.authMethod === 'oidc'
595
+ ? await this.ensureValidOidcToken().catch(() => null)
596
+ : this.authToken);
597
+ if (requiresAuth && authToken)
598
+ headers['Authorization'] = `Bearer ${authToken}`;
392
599
  if (this.socket.id)
393
600
  headers['X-Socket-ID'] = this.socket.id;
394
601
  const options = {
@@ -406,6 +613,97 @@ export default class docPouchClient {
406
613
  }
407
614
  throw new Error(`API error: ${response.status} ${response.statusText}`);
408
615
  }
616
+ if (response.status === 204)
617
+ return undefined;
409
618
  return await response.json();
410
619
  }
620
+ // OIDC Private Helpers
621
+ async discoverOidc() {
622
+ const response = await fetch(`${this.oidcConfig.issuer}/.well-known/openid-configuration`);
623
+ return response.json();
624
+ }
625
+ setOidcTokens(tokens) {
626
+ this.authMethod = 'oidc';
627
+ this.oidcAccessToken = tokens.accessToken;
628
+ this.oidcRefreshToken = tokens.refreshToken || this.oidcRefreshToken;
629
+ this.oidcIdToken = tokens.idToken || null;
630
+ this.oidcTokenExpiry = Date.now() + (tokens.expiresIn * 1000);
631
+ this.persistOidcSession();
632
+ }
633
+ clearOidcTokens() {
634
+ this.oidcAccessToken = null;
635
+ this.oidcRefreshToken = null;
636
+ this.oidcIdToken = null;
637
+ this.oidcTokenExpiry = 0;
638
+ this.codeVerifier = null;
639
+ this.oidcState = null;
640
+ if (this.authMethod === 'oidc')
641
+ this.authMethod = 'none';
642
+ this.clearPersistedOidcSession();
643
+ }
644
+ persistOidcSession() {
645
+ if (typeof window !== 'undefined' && window.localStorage) {
646
+ const session = {
647
+ accessToken: this.oidcAccessToken,
648
+ refreshToken: this.oidcRefreshToken,
649
+ idToken: this.oidcIdToken,
650
+ expiry: this.oidcTokenExpiry
651
+ };
652
+ localStorage.setItem('docpouch_oidc_session', JSON.stringify(session));
653
+ }
654
+ }
655
+ clearPersistedOidcSession() {
656
+ if (typeof window !== 'undefined' && window.localStorage) {
657
+ localStorage.removeItem('docpouch_oidc_session');
658
+ }
659
+ }
660
+ async refreshOidcToken() {
661
+ if (!this.oidcRefreshToken)
662
+ throw new Error('No refresh token available');
663
+ const discovery = await this.discoverOidc();
664
+ const body = new URLSearchParams({
665
+ grant_type: 'refresh_token',
666
+ refresh_token: this.oidcRefreshToken,
667
+ client_id: this.oidcConfig.clientId
668
+ });
669
+ const response = await fetch(discovery.token_endpoint, {
670
+ method: 'POST',
671
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
672
+ body: body.toString()
673
+ });
674
+ if (!response.ok) {
675
+ this.clearOidcTokens();
676
+ throw new Error('Token refresh failed');
677
+ }
678
+ const raw = await response.json();
679
+ const tokens = {
680
+ accessToken: raw.access_token,
681
+ refreshToken: raw.refresh_token,
682
+ idToken: raw.id_token,
683
+ expiresIn: raw.expires_in,
684
+ tokenType: raw.token_type,
685
+ scope: raw.scope
686
+ };
687
+ this.setOidcTokens(tokens);
688
+ }
689
+ generateCodeVerifier() {
690
+ const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
691
+ const array = new Uint8Array(64);
692
+ crypto.getRandomValues(array);
693
+ return Array.from(array, byte => charset[byte % charset.length]).join('');
694
+ }
695
+ async generateCodeChallenge(verifier) {
696
+ const encoder = new TextEncoder();
697
+ const data = encoder.encode(verifier);
698
+ const digest = await crypto.subtle.digest('SHA-256', data);
699
+ return btoa(String.fromCharCode(...new Uint8Array(digest)))
700
+ .replace(/\+/g, '-')
701
+ .replace(/\//g, '_')
702
+ .replace(/=+$/, '');
703
+ }
704
+ generateState() {
705
+ const array = new Uint8Array(32);
706
+ crypto.getRandomValues(array);
707
+ return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
708
+ }
411
709
  }
@@ -11,7 +11,7 @@ module.exports = {
11
11
  },
12
12
  extensionsToTreatAsEsm: ['.ts'],
13
13
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
14
- testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
14
+ testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)', 'tests/**/*.test.ts'],
15
15
  collectCoverage: true,
16
16
  coverageDirectory: 'coverage',
17
17
  coverageReporters: ['text', 'lcov'],
package/package.json CHANGED
@@ -1,38 +1,41 @@
1
- {
2
- "name": "docpouch-client",
3
- "version": "0.9.1",
4
- "main": "dist/index.js",
5
- "types": "dist/index.d.ts",
6
- "exports": {
7
- ".": {
8
- "import": "./dist/index.js",
9
- "require": "./dist/index.js"
10
- }
11
- },
12
- "type": "module",
13
- "scripts": {
14
- "build": "tsc",
15
- "test": "jest"
16
- },
17
- "keywords": [
18
- "docPouch",
19
- "API"
20
- ],
21
- "author": "Jan Frecè",
22
- "license": "MIT",
23
- "description": "A small class to more easily access the docPouch API",
24
- "repository": {
25
- "type": "git",
26
- "url": "https://github.com/BFH-JTF/docpouch-client"
27
- },
28
- "devDependencies": {
29
- "@types/jest": "^30.0.0",
30
- "@types/node": "^24.12.0",
31
- "jest": "^30.3.0",
32
- "ts-jest": "^29.4.9",
33
- "typescript": "^5.9.3"
34
- },
35
- "dependencies": {
36
- "socket.io-client": "^4.8.3"
37
- }
38
- }
1
+ {
2
+ "name": "docpouch-client",
3
+ "version": "1.0.1",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "require": "./dist/index.js"
10
+ }
11
+ },
12
+ "type": "module",
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "test": "node --experimental-vm-modules --no-warnings ./node_modules/jest/bin/jest.js --forceExit --silent"
16
+ },
17
+ "keywords": [
18
+ "docPouch",
19
+ "API"
20
+ ],
21
+ "author": "Jan Frecè",
22
+ "license": "MIT",
23
+ "description": "Client SDK for DocPouch API - supports JWT and OIDC authentication",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/BFH-JTF/docpouch-client"
27
+ },
28
+ "devDependencies": {
29
+ "@types/express": "^5.0.6",
30
+ "@types/jest": "^30.0.0",
31
+ "@types/node": "^25.7.0",
32
+ "express": "^5.2.1",
33
+ "jest": "^30.4.2",
34
+ "socket.io": "^4.8.3",
35
+ "ts-jest": "^29.4.9",
36
+ "typescript": "^6.0.3"
37
+ },
38
+ "dependencies": {
39
+ "socket.io-client": "^4.8.3"
40
+ }
41
+ }