@karpeleslab/klbfw 0.2.26 → 0.2.28

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/README.md CHANGED
@@ -167,6 +167,43 @@ The upload module provides methods to manage active uploads:
167
167
  - `upload.retryItem(uploadId)`: Retry a failed upload
168
168
  - `upload.deleteItem(uploadId)`: Remove an upload from the queue or failed list
169
169
 
170
+ ## Authentication
171
+
172
+ Browser apps don't need to do anything — `rest()`, `restSSE()`, and `uploadFile()` send the FW session token as `Authorization: Session <token>` and rely on `credentials: 'include'` for the session cookie. This is the default `sessionAuth` provider.
173
+
174
+ Node.js apps cannot use session cookies. They opt in to OAuth2 Bearer auth from the separate `auth-node` entry point, which is intentionally not part of the main bundle (so browser bundlers never pull in `fs`/`https`/`os`).
175
+
176
+ ```javascript
177
+ const klbfw = require('@karpeleslab/klbfw');
178
+ const { AuthInfo, bearerAuth } = require('@karpeleslab/klbfw/auth-node');
179
+
180
+ const info = new AuthInfo();
181
+ await info.init();
182
+ try {
183
+ await info.load();
184
+ } catch (_) {
185
+ await info.login(); // Prints a URL the user has to open
186
+ await info.save();
187
+ }
188
+
189
+ klbfw.setAuth(bearerAuth(info));
190
+
191
+ // rest()/uploadFile()/restSSE() now send Bearer tokens.
192
+ // Expired access_tokens are renewed transparently via the refresh_token,
193
+ // the refreshed token is written back to disk, and the failed call is
194
+ // retried once.
195
+ ```
196
+
197
+ ### setAuth(provider) / getAuth() / sessionAuth
198
+
199
+ `setAuth(provider)` swaps the active auth provider for all subsequent `rest()`, `restSSE()`, and `uploadFile()` calls. Pass `null` to restore the default `sessionAuth`.
200
+
201
+ A provider is an object with three methods:
202
+
203
+ - `applyToRequest(headers, fetchOptions)` — set Authorization header, credentials mode, etc.
204
+ - `refreshIfNeeded()` — return a Promise that resolves once the token is fresh.
205
+ - `handleExpiredError(error)` — return `Promise<true>` if the provider successfully refreshed and the call should be retried once.
206
+
170
207
  ## Query Parameter Methods
171
208
 
172
209
  ### GET
package/auth-node.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Node-only auth helpers for @karpeleslab/klbfw.
3
+ *
4
+ * Not exported from the main entry; require it directly from Node:
5
+ *
6
+ * const { AuthInfo, bearerAuth } = require('@karpeleslab/klbfw/auth-node');
7
+ * const klbfw = require('@karpeleslab/klbfw');
8
+ *
9
+ * const info = new AuthInfo();
10
+ * await info.init();
11
+ * try { await info.load(); } catch (_) { await info.login(); await info.save(); }
12
+ * klbfw.setAuth(bearerAuth(info));
13
+ */
14
+
15
+ import { AuthProvider } from './index';
16
+
17
+ /** Token payload returned by the OAuth2 token endpoint. */
18
+ export interface AuthToken {
19
+ access_token: string;
20
+ refresh_token?: string;
21
+ token_type?: string;
22
+ expires_in?: number;
23
+ ClientID?: string;
24
+ [key: string]: any;
25
+ }
26
+
27
+ /** Constructor options for AuthInfo. */
28
+ export interface AuthInfoOptions {
29
+ /** Profile name — used in the on-disk filename. Defaults to $SHELLS_PROFILE or 'default'. */
30
+ profile?: string;
31
+ /** OAuth2 client_id. */
32
+ clientId?: string;
33
+ /** API host (e.g. 'hub.atonline.com'). */
34
+ apiHost?: string;
35
+ /** API base path (e.g. '/_special/rest/'). */
36
+ apiBasePath?: string;
37
+ }
38
+
39
+ /**
40
+ * Holds an OAuth2 access/refresh token pair and persists it to
41
+ * ~/.config/atonline/auth-<profile>.json.
42
+ */
43
+ export class AuthInfo {
44
+ constructor(options?: AuthInfoOptions);
45
+ token: AuthToken | null;
46
+ name: string;
47
+ clientId: string;
48
+ apiHost: string;
49
+ apiBasePath: string;
50
+ filepath: string | null;
51
+
52
+ /** Create the config dir and resolve the on-disk path. Call before load/save. */
53
+ init(): Promise<void>;
54
+ /** Load the persisted token. Throws if no token has been saved yet. */
55
+ load(): Promise<void>;
56
+ /** Persist the current token to disk (mode 0600). */
57
+ save(): Promise<void>;
58
+ /** Run the OAuth2 polltoken login flow. Prints a URL the user has to open. */
59
+ login(): Promise<void>;
60
+ /** Exchange the refresh_token for a fresh access_token. */
61
+ renewToken(): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Build an auth provider from an AuthInfo instance. Pass the result to setAuth().
66
+ */
67
+ export function bearerAuth(authInfo: AuthInfo): AuthProvider;
package/auth-node.js ADDED
@@ -0,0 +1,255 @@
1
+ 'use strict';
2
+ /**
3
+ * @fileoverview Node-only OAuth2 auth provider for KLB Frontend Framework
4
+ *
5
+ * This module is intentionally NOT re-exported from index.js. It pulls in
6
+ * Node built-ins (`fs`, `os`, `path`) which would break browser bundlers if
7
+ * they followed the main entry. Node applications opt in explicitly:
8
+ *
9
+ * const klbfw = require('@karpeleslab/klbfw');
10
+ * const { AuthInfo, bearerAuth } = require('@karpeleslab/klbfw/auth-node');
11
+ *
12
+ * const info = new AuthInfo();
13
+ * await info.init();
14
+ * try { await info.load(); } catch (_) { await info.login(); await info.save(); }
15
+ * klbfw.setAuth(bearerAuth(info));
16
+ *
17
+ * // Subsequent rest()/uploadFile() calls now use the Bearer token,
18
+ * // refresh the token automatically when the API reports it expired,
19
+ * // and persist the refreshed token to disk.
20
+ */
21
+
22
+ const fs = require('fs').promises;
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ const DEFAULT_CLIENT_ID = 'oaap-p6rktp-uzaf-adle-djqw-g27ghobe';
27
+ const DEFAULT_API_HOST = 'hub.atonline.com';
28
+ const DEFAULT_API_BASE_PATH = '/_special/rest/';
29
+
30
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
31
+
32
+ /**
33
+ * Holds an OAuth2 access/refresh token pair, persists it to
34
+ * ~/.config/atonline/auth-<profile>.json, and knows how to re-acquire one
35
+ * via the polltoken login flow.
36
+ */
37
+ class AuthInfo {
38
+ constructor(options) {
39
+ options = options || {};
40
+ this.token = null;
41
+ this.name = options.profile || process.env.SHELLS_PROFILE || 'default';
42
+ this.clientId = options.clientId || DEFAULT_CLIENT_ID;
43
+ this.apiHost = options.apiHost || DEFAULT_API_HOST;
44
+ this.apiBasePath = options.apiBasePath || DEFAULT_API_BASE_PATH;
45
+ this.filepath = null;
46
+ }
47
+
48
+ async init() {
49
+ const configDir = path.join(os.homedir(), '.config', 'atonline');
50
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
51
+ this.filepath = path.join(configDir, `auth-${this.name}.json`);
52
+ }
53
+
54
+ async load() {
55
+ if (!this.filepath) {
56
+ throw new Error('AuthInfo.init() must be called before load()');
57
+ }
58
+ try {
59
+ const data = await fs.readFile(this.filepath, 'utf8');
60
+ this.token = JSON.parse(data);
61
+ this.token.ClientID = this.clientId;
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT') {
64
+ throw new Error('No login information found');
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ async save() {
71
+ if (!this.filepath) {
72
+ throw new Error('AuthInfo.init() must be called before save()');
73
+ }
74
+ if (!this.token) {
75
+ throw new Error('No token to save');
76
+ }
77
+ await fs.writeFile(this.filepath, JSON.stringify(this.token, null, 2), { mode: 0o600 });
78
+ }
79
+
80
+ /**
81
+ * Run the OAuth2 polltoken login flow. Prints an authorization URL the
82
+ * user has to open, then polls until the user completes the flow.
83
+ */
84
+ async login() {
85
+ const tokenCreate = await this._unauthRequest('POST', `OAuth2/App/${this.clientId}:token_create`, {});
86
+ const polltoken = tokenCreate.polltoken;
87
+ if (!polltoken) {
88
+ throw new Error('Failed to fetch polltoken');
89
+ }
90
+
91
+ const tokuri = encodeURIComponent(`polltoken:${polltoken}`);
92
+ let fulluri = `https://${this.apiHost}/_rest/OAuth2:auth?response_type=code&client_id=${this.clientId}&redirect_uri=${tokuri}&scope=profile`;
93
+ if (tokenCreate.xox) {
94
+ fulluri = tokenCreate.xox;
95
+ }
96
+
97
+ console.log('Please open this URL in order to login:');
98
+ console.log(fulluri);
99
+
100
+ while (true) {
101
+ const pollResult = await this._unauthRequest('POST', `OAuth2/App/${this.clientId}:token_poll`, { polltoken });
102
+
103
+ if (!pollResult.response) {
104
+ await sleep(1000);
105
+ continue;
106
+ }
107
+
108
+ const code = pollResult.response.code;
109
+ if (!code) {
110
+ throw new Error('Invalid response from API, response not containing code');
111
+ }
112
+
113
+ const tokenResponse = await this._tokenExchange({
114
+ client_id: this.clientId,
115
+ grant_type: 'authorization_code',
116
+ code: code
117
+ });
118
+ this.token = tokenResponse;
119
+ this.token.ClientID = this.clientId;
120
+ return;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Exchange the refresh_token for a fresh access_token. Throws if the
126
+ * refresh fails — the caller should usually run login() again.
127
+ */
128
+ async renewToken() {
129
+ if (!this.token || !this.token.refresh_token) {
130
+ throw new Error('No refresh token is available and access token has expired');
131
+ }
132
+ const oldToken = this.token;
133
+ this.token = null;
134
+ try {
135
+ const response = await this._tokenExchange({
136
+ grant_type: 'refresh_token',
137
+ client_id: oldToken.ClientID || this.clientId,
138
+ refresh_token: oldToken.refresh_token
139
+ });
140
+ this.token = Object.assign({}, oldToken, response, {
141
+ ClientID: oldToken.ClientID || this.clientId
142
+ });
143
+ if (this.filepath) {
144
+ await this.save();
145
+ }
146
+ } catch (error) {
147
+ this.token = oldToken;
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------
153
+ // Private helpers — these intentionally bypass the rest.js pipeline
154
+ // because they need to run *without* an active access_token.
155
+
156
+ _request(method, path, body, headers, isForm) {
157
+ const https = require('https');
158
+ return new Promise((resolve, reject) => {
159
+ const options = {
160
+ hostname: this.apiHost,
161
+ path: this.apiBasePath + path,
162
+ method: method,
163
+ headers: Object.assign({}, headers || {})
164
+ };
165
+
166
+ let payload = '';
167
+ if (body !== undefined && body !== null) {
168
+ payload = isForm ? body : JSON.stringify(body);
169
+ options.headers['Content-Length'] = Buffer.byteLength(payload);
170
+ if (!options.headers['Content-Type']) {
171
+ options.headers['Content-Type'] = isForm
172
+ ? 'application/x-www-form-urlencoded'
173
+ : 'application/json';
174
+ }
175
+ }
176
+
177
+ const req = https.request(options, (res) => {
178
+ let data = '';
179
+ res.on('data', (chunk) => { data += chunk; });
180
+ res.on('end', () => {
181
+ if (res.statusCode !== 200) {
182
+ reject(new Error(`Invalid status code from server: ${res.statusCode}`));
183
+ return;
184
+ }
185
+ try {
186
+ resolve(JSON.parse(data));
187
+ } catch (err) {
188
+ reject(new Error(`Failed to parse response: ${err.message}`));
189
+ }
190
+ });
191
+ });
192
+ req.on('error', reject);
193
+ if (payload) req.write(payload);
194
+ req.end();
195
+ });
196
+ }
197
+
198
+ async _unauthRequest(method, path, body) {
199
+ const response = await this._request(method, path, body, { 'Sec-Rest-Http': 'false' }, false);
200
+ if (response.result === 'error') {
201
+ throw new Error(response.error || 'API error');
202
+ }
203
+ return response.data || response;
204
+ }
205
+
206
+ async _tokenExchange(params) {
207
+ const { URLSearchParams } = require('url');
208
+ const form = new URLSearchParams(params).toString();
209
+ return this._request('POST', 'OAuth2:token', form, {
210
+ 'Content-Type': 'application/x-www-form-urlencoded'
211
+ }, true);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Returns an auth provider that authenticates via the OAuth2 access_token
217
+ * carried by an AuthInfo instance. Plug it into klbfw via setAuth().
218
+ */
219
+ const bearerAuth = (authInfo) => ({
220
+ name: 'bearer',
221
+ authInfo: authInfo,
222
+
223
+ applyToRequest(headers, _fetchOptions) {
224
+ if (authInfo.token && authInfo.token.access_token) {
225
+ headers['Authorization'] = 'Bearer ' + authInfo.token.access_token;
226
+ }
227
+ // Intentionally do NOT set credentials: 'include' — Node has no cookie jar.
228
+ },
229
+
230
+ refreshIfNeeded() {
231
+ // No proactive refresh — the API tells us when the token is gone.
232
+ return Promise.resolve();
233
+ },
234
+
235
+ async handleExpiredError(error) {
236
+ if (!isExpiredTokenError(error)) return false;
237
+ if (!authInfo.token || !authInfo.token.refresh_token) return false;
238
+ try {
239
+ await authInfo.renewToken();
240
+ return true;
241
+ } catch (_) {
242
+ return false;
243
+ }
244
+ }
245
+ });
246
+
247
+ const isExpiredTokenError = (error) => {
248
+ if (!error) return false;
249
+ if (error.token === 'error_login_required') return true;
250
+ if (error.token === 'invalid_request_token' && error.extra === 'token_expired') return true;
251
+ return false;
252
+ };
253
+
254
+ module.exports.AuthInfo = AuthInfo;
255
+ module.exports.bearerAuth = bearerAuth;
package/auth.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ /**
3
+ * @fileoverview Pluggable auth provider for KLB Frontend Framework
4
+ *
5
+ * The default `sessionAuth` provider preserves browser behavior: the FW
6
+ * session token is sent as an `Authorization: Session <token>` header and
7
+ * fetch requests use `credentials: 'include'` so the session cookie travels
8
+ * with each call.
9
+ *
10
+ * Node.js applications cannot use session cookies. They should require
11
+ * `@karpeleslab/klbfw/auth-node` and call `setAuth(bearerAuth(authInfo))`
12
+ * once at startup. All `rest()`, `restGet()`, `restSSE()` and `uploadFile()`
13
+ * calls then send a Bearer token instead.
14
+ *
15
+ * An auth provider implements:
16
+ * - applyToRequest(headers, fetchOptions): mutate headers / fetch options
17
+ * - refreshIfNeeded(): Promise resolving once the token is fresh
18
+ * - handleExpiredError(error): Promise<boolean> — true to retry
19
+ */
20
+
21
+ const fwWrapper = require('./fw-wrapper');
22
+
23
+ const FIVE_MINUTES = 5 * 60 * 1000;
24
+
25
+ /**
26
+ * Default auth provider — browser session cookie + FW.token.
27
+ * Behavior matches the pre-auth-abstraction inline logic.
28
+ */
29
+ const sessionAuth = {
30
+ name: 'session',
31
+
32
+ applyToRequest(headers, fetchOptions) {
33
+ const token = fwWrapper.getToken();
34
+ if (token !== '') {
35
+ headers['Authorization'] = 'Session ' + token;
36
+ }
37
+ fetchOptions.credentials = 'include';
38
+ },
39
+
40
+ refreshIfNeeded() {
41
+ const tokenExp = fwWrapper.getTokenExp();
42
+
43
+ if (tokenExp === undefined) {
44
+ return Promise.resolve();
45
+ }
46
+
47
+ if (tokenExp - Date.now() > FIVE_MINUTES) {
48
+ return Promise.resolve();
49
+ }
50
+
51
+ // Lazy require to break circular load with internal.js
52
+ const internal = require('./internal');
53
+ const callUrl = internal.buildRestUrl('_special/token.json', true);
54
+
55
+ const headers = {};
56
+ const token = fwWrapper.getToken();
57
+ if (token !== '') {
58
+ headers['Authorization'] = 'Session ' + token;
59
+ }
60
+
61
+ return fetch(callUrl, {
62
+ method: 'GET',
63
+ credentials: 'include',
64
+ headers: headers
65
+ })
66
+ .then(response => {
67
+ if (!response.ok) {
68
+ fwWrapper.setToken(fwWrapper.getToken(), undefined);
69
+ return;
70
+ }
71
+
72
+ const contentType = response.headers.get('content-type');
73
+ if (!contentType || contentType.indexOf('application/json') === -1) {
74
+ fwWrapper.setToken(fwWrapper.getToken(), undefined);
75
+ return;
76
+ }
77
+
78
+ return response.json();
79
+ })
80
+ .then(json => {
81
+ if (json && json.token && json.token_exp) {
82
+ fwWrapper.setToken(json.token, json.token_exp);
83
+ } else {
84
+ fwWrapper.setToken(fwWrapper.getToken(), undefined);
85
+ }
86
+ })
87
+ .catch(() => {
88
+ fwWrapper.setToken(fwWrapper.getToken(), undefined);
89
+ });
90
+ },
91
+
92
+ handleExpiredError(_error) {
93
+ // Browser sessions can't silently re-authenticate.
94
+ return Promise.resolve(false);
95
+ }
96
+ };
97
+
98
+ let currentAuth = sessionAuth;
99
+
100
+ /**
101
+ * Replaces the active auth provider for all subsequent rest()/upload calls.
102
+ * Pass null/undefined to restore the default sessionAuth.
103
+ */
104
+ const setAuth = (auth) => {
105
+ currentAuth = auth || sessionAuth;
106
+ };
107
+
108
+ /** Returns the current active auth provider. */
109
+ const getAuth = () => currentAuth;
110
+
111
+ module.exports.sessionAuth = sessionAuth;
112
+ module.exports.setAuth = setAuth;
113
+ module.exports.getAuth = getAuth;
package/index.d.ts CHANGED
@@ -61,6 +61,25 @@ interface RestResponse<T = any> {
61
61
  [key: string]: any;
62
62
  }
63
63
 
64
+ /**
65
+ * Context object for REST API calls.
66
+ * Keys are single characters representing different context dimensions.
67
+ */
68
+ interface Context {
69
+ /** Branch identifier */
70
+ b?: string;
71
+ /** Currency code (e.g., 'USD', 'EUR') */
72
+ c?: string;
73
+ /** Group identifier */
74
+ g?: string;
75
+ /** Language/locale code (e.g., 'en-US', 'ja-JP') */
76
+ l?: string;
77
+ /** Timezone identifier (e.g., 'Asia/Tokyo', 'America/New_York') */
78
+ t?: string;
79
+ /** User identifier */
80
+ u?: string;
81
+ }
82
+
64
83
  /** REST API error object (thrown on promise rejection) */
65
84
  interface RestError {
66
85
  /** Always 'error' for error responses */
@@ -172,7 +191,7 @@ interface Price extends PriceValue {
172
191
  tax_rate?: number;
173
192
  }
174
193
 
175
- declare function rest<T = any>(name: string, verb: string, params?: Record<string, any>, context?: Record<string, any>): Promise<RestResponse<T>>;
194
+ declare function rest<T = any>(name: string, verb: string, params?: Record<string, any>, context?: Context): Promise<RestResponse<T>>;
176
195
  declare function rest_get<T = any>(name: string, params?: Record<string, any>): Promise<RestResponse<T>>; // Backward compatibility
177
196
  declare function restGet<T = any>(name: string, params?: Record<string, any>): Promise<RestResponse<T>>;
178
197
 
@@ -218,7 +237,7 @@ interface SSESource {
218
237
  close(): void;
219
238
  }
220
239
 
221
- declare function restSSE(name: string, method?: string, params?: Record<string, any>, context?: Record<string, any>): SSESource;
240
+ declare function restSSE(name: string, method?: string, params?: Record<string, any>, context?: Context): SSESource;
222
241
 
223
242
  // Upload module types
224
243
 
@@ -266,7 +285,7 @@ interface UploadLegacyOptions {
266
285
  /** @deprecated Use uploadFile() instead */
267
286
  declare const upload: {
268
287
  init(path: string, params?: Record<string, any>, notify?: (status: any) => void): Promise<any> | ((files: any) => Promise<any>);
269
- append(path: string, file: File | object, params?: Record<string, any>, context?: Record<string, any>): Promise<any>;
288
+ append(path: string, file: File | object, params?: Record<string, any>, context?: Context): Promise<any>;
270
289
  run(): void;
271
290
  getStatus(): { queue: any[]; running: any[]; failed: any[] };
272
291
  resume(): void;
@@ -284,7 +303,7 @@ declare function uploadFile(
284
303
  buffer: UploadFileInput,
285
304
  method?: string,
286
305
  params?: Record<string, any>,
287
- context?: Record<string, any>,
306
+ context?: Context,
288
307
  options?: UploadFileOptions
289
308
  ): Promise<any>;
290
309
 
@@ -294,10 +313,45 @@ declare function uploadManyFiles(
294
313
  files: UploadFileInput[],
295
314
  method?: string,
296
315
  params?: Record<string, any>,
297
- context?: Record<string, any>,
316
+ context?: Context,
298
317
  options?: UploadManyFilesOptions
299
318
  ): Promise<any[]>;
300
319
 
320
+ // Auth provider types
321
+
322
+ /**
323
+ * Pluggable auth provider interface used by rest(), restSSE(), and uploadFile().
324
+ *
325
+ * The default `sessionAuth` uses browser session cookies + FW.token. Node
326
+ * applications should require '@karpeleslab/klbfw/auth-node' and call
327
+ * `setAuth(bearerAuth(authInfo))` once at startup to switch to OAuth2 Bearer.
328
+ */
329
+ interface AuthProvider {
330
+ /** Optional human-readable name (e.g. 'session', 'bearer'). */
331
+ name?: string;
332
+ /**
333
+ * Mutate `headers` and `fetchOptions` to carry credentials. The default
334
+ * provider sets `Authorization: Session <token>` and credentials: 'include';
335
+ * a Bearer provider sets `Authorization: Bearer <access_token>` only.
336
+ */
337
+ applyToRequest(headers: Record<string, string>, fetchOptions: Record<string, any>): void;
338
+ /** Resolves once the credential is fresh enough to use. */
339
+ refreshIfNeeded(): Promise<void>;
340
+ /**
341
+ * Called when a request rejects with an API error. Return true if the
342
+ * provider successfully refreshed the credential and the caller should
343
+ * retry the request once.
344
+ */
345
+ handleExpiredError(error: any): Promise<boolean> | boolean;
346
+ }
347
+
348
+ /** Replace the active auth provider. Pass null/undefined to restore the default. */
349
+ declare function setAuth(provider: AuthProvider | null | undefined): void;
350
+ /** Get the active auth provider. */
351
+ declare function getAuth(): AuthProvider;
352
+ /** Default auth provider — browser session cookie + FW.token. */
353
+ declare const sessionAuth: AuthProvider;
354
+
301
355
  // Utility types
302
356
  declare function getI18N(key: string, args?: Record<string, any>): string;
303
357
  declare function trimPrefix(path: string): string;
@@ -332,8 +386,13 @@ export {
332
386
  upload,
333
387
  uploadFile,
334
388
  uploadManyFiles,
389
+ setAuth,
390
+ getAuth,
391
+ sessionAuth,
392
+ AuthProvider,
335
393
  getI18N,
336
394
  trimPrefix,
395
+ Context,
337
396
  RestPaging,
338
397
  RestResponse,
339
398
  RestError,
package/index.js CHANGED
@@ -13,6 +13,7 @@ const uploadMany = require('./upload-many');
13
13
  const uploadLegacy = require('./upload-legacy');
14
14
  const util = require('./util');
15
15
  const cookies = require('./cookies');
16
+ const auth = require('./auth');
16
17
 
17
18
  // Framework wrapper exports
18
19
  module.exports.GET = internalFW.GET; // Use the function directly
@@ -52,6 +53,11 @@ module.exports.upload = uploadLegacy.upload;
52
53
  module.exports.uploadFile = upload.uploadFile;
53
54
  module.exports.uploadManyFiles = uploadMany.uploadManyFiles;
54
55
 
56
+ // Auth provider exports — see auth-node.js for the Node-only Bearer provider.
57
+ module.exports.setAuth = auth.setAuth;
58
+ module.exports.getAuth = auth.getAuth;
59
+ module.exports.sessionAuth = auth.sessionAuth;
60
+
55
61
  // Utility exports
56
62
  module.exports.getI18N = util.getI18N;
57
63
  module.exports.trimPrefix = util.trimPrefix;
package/internal.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const fwWrapper = require('./fw-wrapper');
10
+ const auth = require('./auth');
10
11
 
11
12
  /**
12
13
  * Pads a number with leading zeros
@@ -122,68 +123,12 @@ const checkSupport = () => {
122
123
  };
123
124
 
124
125
  /**
125
- * Checks if token needs refresh and refreshes if necessary
126
+ * Checks if token needs refresh and refreshes if necessary.
127
+ * Delegates to the active auth provider's `refreshIfNeeded` so Node-side
128
+ * Bearer flows can plug in their own renewal logic.
126
129
  * @returns {Promise<void>} Resolves when check/refresh is complete
127
130
  */
128
- const checkAndRefreshToken = () => {
129
- const tokenExp = fwWrapper.getTokenExp();
130
-
131
- // If token_exp is not defined, no refresh needed
132
- if (tokenExp === undefined) {
133
- return Promise.resolve();
134
- }
135
-
136
- const now = Date.now();
137
- const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
138
-
139
- // Check if token expires within 5 minutes
140
- if (tokenExp - now <= fiveMinutes) {
141
- // Need to refresh token
142
- const callUrl = buildRestUrl('_special/token.json', true);
143
- const headers = {};
144
-
145
- if (fwWrapper.getToken() !== '') {
146
- headers['Authorization'] = 'Session ' + fwWrapper.getToken();
147
- }
148
-
149
- return fetch(callUrl, {
150
- method: 'GET',
151
- credentials: 'include',
152
- headers: headers
153
- })
154
- .then(response => {
155
- if (!response.ok) {
156
- // API returned an error, give up by setting token_exp to undefined
157
- fwWrapper.setToken(fwWrapper.getToken(), undefined);
158
- return;
159
- }
160
-
161
- const contentType = response.headers.get('content-type');
162
- if (!contentType || contentType.indexOf('application/json') === -1) {
163
- // Not JSON response, give up
164
- fwWrapper.setToken(fwWrapper.getToken(), undefined);
165
- return;
166
- }
167
-
168
- return response.json();
169
- })
170
- .then(json => {
171
- if (json && json.token && json.token_exp) {
172
- // Update token and token_exp
173
- fwWrapper.setToken(json.token, json.token_exp);
174
- } else {
175
- // Invalid response, give up
176
- fwWrapper.setToken(fwWrapper.getToken(), undefined);
177
- }
178
- })
179
- .catch(() => {
180
- // Error occurred, give up by setting token_exp to undefined
181
- fwWrapper.setToken(fwWrapper.getToken(), undefined);
182
- });
183
- }
184
-
185
- return Promise.resolve();
186
- };
131
+ const checkAndRefreshToken = () => auth.getAuth().refreshIfNeeded();
187
132
 
188
133
  /**
189
134
  * Makes an internal REST API call
@@ -206,56 +151,30 @@ const internalRest = (name, verb, params, context) => {
206
151
  return checkAndRefreshToken().then(() => {
207
152
  const callUrl = buildRestUrl(name, true, context);
208
153
  const headers = {};
154
+ const fetchOptions = { method: verb, headers: headers };
209
155
 
210
- if (fwWrapper.getToken() !== '') {
211
- headers['Authorization'] = 'Session ' + fwWrapper.getToken();
212
- }
156
+ // Active auth provider sets Authorization header and credentials mode.
157
+ auth.getAuth().applyToRequest(headers, fetchOptions);
213
158
 
214
159
  // Handle GET requests
215
160
  if (verb === "GET") {
216
161
  if (params) {
217
- // Check if params is a JSON string, or if it needs encoding
218
- if (typeof params === "string") {
219
- return fetch(callUrl + "&_=" + encodeURIComponent(params), {
220
- method: verb,
221
- credentials: 'include',
222
- headers: headers
223
- });
224
- } else {
225
- return fetch(callUrl + "&_=" + encodeURIComponent(JSON.stringify(params)), {
226
- method: verb,
227
- credentials: 'include',
228
- headers: headers
229
- });
230
- }
162
+ const encoded = typeof params === "string" ? params : JSON.stringify(params);
163
+ return fetch(callUrl + "&_=" + encodeURIComponent(encoded), fetchOptions);
231
164
  }
232
-
233
- return fetch(callUrl, {
234
- method: verb,
235
- credentials: 'include',
236
- headers: headers
237
- });
165
+ return fetch(callUrl, fetchOptions);
238
166
  }
239
167
 
240
168
  // Handle FormData
241
169
  if (typeof FormData !== "undefined" && (params instanceof FormData)) {
242
- return fetch(callUrl, {
243
- method: verb,
244
- credentials: 'include',
245
- body: params,
246
- headers: headers
247
- });
170
+ fetchOptions.body = params;
171
+ return fetch(callUrl, fetchOptions);
248
172
  }
249
173
 
250
174
  // Handle JSON requests
251
175
  headers['Content-Type'] = 'application/json; charset=utf-8';
252
-
253
- return fetch(callUrl, {
254
- method: verb,
255
- credentials: 'include',
256
- body: JSON.stringify(params),
257
- headers: headers
258
- });
176
+ fetchOptions.body = JSON.stringify(params);
177
+ return fetch(callUrl, fetchOptions);
259
178
  });
260
179
  };
261
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karpeleslab/klbfw",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "Frontend Framework",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -30,14 +30,14 @@
30
30
  "js-sha256": "^0.11.0"
31
31
  },
32
32
  "optionalDependencies": {
33
- "node-fetch": "^2.7.0",
34
- "@xmldom/xmldom": "~0.8.4"
33
+ "@xmldom/xmldom": "~0.8.4",
34
+ "node-fetch": "^2.7.0"
35
35
  },
36
36
  "devDependencies": {
37
- "jest": "^29.7.0",
38
- "jest-environment-jsdom": "^29.7.0",
39
- "node-fetch": "^2.7.0",
40
- "@xmldom/xmldom": "~0.8.4"
37
+ "@xmldom/xmldom": "~0.8.4",
38
+ "jest": "^30.3.0",
39
+ "jest-environment-jsdom": "^30.3.0",
40
+ "node-fetch": "^2.7.0"
41
41
  },
42
42
  "jest": {
43
43
  "testEnvironment": "jsdom",
package/rest.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  const internal = require('./internal');
9
9
  const fwWrapper = require('./fw-wrapper');
10
+ const auth = require('./auth');
10
11
 
11
12
  /**
12
13
  * Handles platform-specific API calls
@@ -80,15 +81,15 @@ const rest = (name, verb, params, context) => {
80
81
  return Promise.reject(new Error('Environment not supported'));
81
82
  }
82
83
 
83
- return new Promise((resolve, reject) => {
84
+ const tryOnce = () => new Promise((resolve, reject) => {
84
85
  const handleSuccess = data => {
85
86
  internal.responseParse(data, resolve, reject);
86
87
  };
87
-
88
+
88
89
  const handleError = data => {
89
90
  reject(data);
90
91
  };
91
-
92
+
92
93
  const handleException = error => {
93
94
  console.error(error);
94
95
  // TODO: Add proper error logging
@@ -98,6 +99,13 @@ const rest = (name, verb, params, context) => {
98
99
  .then(handleSuccess, handleError)
99
100
  .catch(handleException);
100
101
  });
102
+
103
+ return tryOnce().catch(err =>
104
+ Promise.resolve(auth.getAuth().handleExpiredError(err)).then(retry => {
105
+ if (retry) return tryOnce();
106
+ throw err;
107
+ })
108
+ );
101
109
  };
102
110
 
103
111
  /**
@@ -300,19 +308,16 @@ const restSSE = (name, method, params, context) => {
300
308
  'Accept': 'text/event-stream, application/json'
301
309
  };
302
310
 
303
- const token = fwWrapper.getToken();
304
- if (token && token !== '') {
305
- headers['Authorization'] = 'Session ' + token;
306
- }
307
-
308
311
  // Build fetch options based on method
309
312
  const fetchOptions = {
310
313
  method: method,
311
- credentials: 'include',
312
314
  headers: headers,
313
315
  signal: abortController.signal
314
316
  };
315
317
 
318
+ // Active auth provider sets Authorization header and credentials mode.
319
+ auth.getAuth().applyToRequest(headers, fetchOptions);
320
+
316
321
  if (method === 'GET') {
317
322
  // For GET requests, add params to URL
318
323
  if (params && Object.keys(params).length > 0) {