@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 +37 -0
- package/auth-node.d.ts +67 -0
- package/auth-node.js +255 -0
- package/auth.js +113 -0
- package/index.d.ts +64 -5
- package/index.js +6 -0
- package/internal.js +15 -96
- package/package.json +7 -7
- package/rest.js +14 -9
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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.
|
|
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
|
-
"
|
|
34
|
-
"
|
|
33
|
+
"@xmldom/xmldom": "~0.8.4",
|
|
34
|
+
"node-fetch": "^2.7.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"
|
|
38
|
-
"jest
|
|
39
|
-
"
|
|
40
|
-
"
|
|
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
|
-
|
|
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) {
|