cf-service-sdk 0.1.18 → 0.1.19
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/client.d.ts +33 -2
- package/dist/client.js +169 -192
- package/dist/generated/graphql.d.ts +0 -2
- package/dist/generated/graphql.js +0 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/mutations.js +0 -1
- package/dist/tokenManager.d.ts +49 -0
- package/dist/tokenManager.js +174 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
|
|
2
|
+
import { TokenManager } from './tokenManager';
|
|
2
3
|
export interface CloudForgeClientOptions {
|
|
3
4
|
baseUrl: string;
|
|
4
5
|
token?: string;
|
|
5
6
|
onUnauthorized?: () => void | Promise<void>;
|
|
6
7
|
onError?: (error: Error) => void;
|
|
7
8
|
onValidationError?: (validationErrors: ValidationError[]) => void;
|
|
9
|
+
/** @deprecated Use the built-in TokenManager instead. Kept for backward compatibility. */
|
|
8
10
|
refreshToken?: () => Promise<string | null>;
|
|
11
|
+
/** Called when token is refreshed (e.g. to persist to localStorage) */
|
|
12
|
+
onTokenRefreshed?: (token: string) => void;
|
|
13
|
+
/** Request credentials mode for HttpLink. Defaults to 'include' for cookie support. */
|
|
14
|
+
credentials?: RequestCredentials;
|
|
9
15
|
}
|
|
10
16
|
export interface ValidationError {
|
|
11
17
|
field: string;
|
|
@@ -14,12 +20,37 @@ export interface ValidationError {
|
|
|
14
20
|
export declare class CloudForgeClient {
|
|
15
21
|
private apolloClient;
|
|
16
22
|
private options;
|
|
17
|
-
|
|
23
|
+
readonly tokenManager: TokenManager;
|
|
24
|
+
private pendingRefreshQueue;
|
|
25
|
+
private isRefreshingLink;
|
|
18
26
|
constructor(options: CloudForgeClientOptions);
|
|
19
27
|
private createApolloClient;
|
|
28
|
+
private isAuthError;
|
|
29
|
+
private createErrorLink;
|
|
30
|
+
/**
|
|
31
|
+
* Retry a failed operation after refreshing the token.
|
|
32
|
+
* Uses a queue to ensure only ONE refresh HTTP call is made, even when
|
|
33
|
+
* multiple operations fail simultaneously (e.g. expired JWT).
|
|
34
|
+
* The first caller triggers the refresh; subsequent callers queue up
|
|
35
|
+
* and are retried (or errored) when the single refresh completes.
|
|
36
|
+
*/
|
|
37
|
+
private retryWithRefresh;
|
|
38
|
+
/**
|
|
39
|
+
* Drain the pending refresh queue: retry all queued operations with
|
|
40
|
+
* the new token, or error them all if refresh failed.
|
|
41
|
+
*/
|
|
42
|
+
private drainQueue;
|
|
43
|
+
/**
|
|
44
|
+
* Update the JWT token. Does NOT recreate the Apollo client -
|
|
45
|
+
* the auth link reads the token dynamically.
|
|
46
|
+
*/
|
|
20
47
|
setToken(token: string): void;
|
|
21
48
|
getClient(): ApolloClient<NormalizedCacheObject>;
|
|
22
49
|
getBaseUrl(): string;
|
|
50
|
+
/** @deprecated Use tokenManager.refresh() instead */
|
|
23
51
|
refreshToken(): Promise<boolean>;
|
|
24
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Logout: revoke refresh token server-side, clear Apollo cache, reset state.
|
|
54
|
+
*/
|
|
55
|
+
logout(): Promise<void>;
|
|
25
56
|
}
|
package/dist/client.js
CHANGED
|
@@ -4,262 +4,239 @@ exports.CloudForgeClient = void 0;
|
|
|
4
4
|
const client_1 = require("@apollo/client");
|
|
5
5
|
const error_1 = require("@apollo/client/link/error");
|
|
6
6
|
const context_1 = require("@apollo/client/link/context");
|
|
7
|
+
const tokenManager_1 = require("./tokenManager");
|
|
7
8
|
const defaultOptions = {
|
|
8
9
|
baseUrl: 'http://localhost:8000/api/graph/',
|
|
9
|
-
|
|
10
|
-
// Detect if we're in a React Native environment
|
|
11
|
-
const isReactNative = () => {
|
|
12
|
-
return typeof global.HermesInternal !== 'undefined' ||
|
|
13
|
-
typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
10
|
+
credentials: 'include',
|
|
14
11
|
};
|
|
15
12
|
class CloudForgeClient {
|
|
16
13
|
constructor(options) {
|
|
17
|
-
this.
|
|
14
|
+
this.pendingRefreshQueue = [];
|
|
15
|
+
this.isRefreshingLink = false;
|
|
18
16
|
this.options = { ...defaultOptions, ...options };
|
|
17
|
+
this.tokenManager = new tokenManager_1.TokenManager({
|
|
18
|
+
baseUrl: this.options.baseUrl,
|
|
19
|
+
onTokenRefreshed: (token) => {
|
|
20
|
+
var _a, _b;
|
|
21
|
+
this.options.token = token;
|
|
22
|
+
(_b = (_a = this.options).onTokenRefreshed) === null || _b === void 0 ? void 0 : _b.call(_a, token);
|
|
23
|
+
},
|
|
24
|
+
onSessionExpired: () => {
|
|
25
|
+
var _a, _b;
|
|
26
|
+
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (this.options.token) {
|
|
30
|
+
this.tokenManager.setToken(this.options.token);
|
|
31
|
+
}
|
|
19
32
|
this.apolloClient = this.createApolloClient();
|
|
20
33
|
}
|
|
21
34
|
createApolloClient() {
|
|
22
|
-
|
|
23
|
-
// This is needed because localhost on a mobile device refers to the device itself
|
|
24
|
-
let apiUrl = this.options.baseUrl;
|
|
25
|
-
// Note: We previously modified the URL here, but now we expect the URL to be
|
|
26
|
-
// properly configured by the application that uses this SDK, especially for React Native
|
|
27
|
-
// applications where localhost means different things on different platforms.
|
|
28
|
-
// HTTP link
|
|
35
|
+
var _a;
|
|
29
36
|
const httpLink = (0, client_1.createHttpLink)({
|
|
30
|
-
uri:
|
|
37
|
+
uri: this.options.baseUrl,
|
|
38
|
+
credentials: (_a = this.options.credentials) !== null && _a !== void 0 ? _a : 'include',
|
|
31
39
|
fetchOptions: {
|
|
32
|
-
cache: 'no-store',
|
|
40
|
+
cache: 'no-store',
|
|
33
41
|
},
|
|
34
42
|
});
|
|
35
|
-
// Auth link
|
|
43
|
+
// Auth link reads token dynamically - no need to recreate client on token change
|
|
36
44
|
const authLink = (0, context_1.setContext)((_, { headers }) => {
|
|
37
|
-
const token = this.
|
|
45
|
+
const token = this.tokenManager.getToken();
|
|
38
46
|
return {
|
|
39
47
|
headers: {
|
|
40
48
|
...headers,
|
|
41
|
-
|
|
49
|
+
...(token ? { authorization: `JWT ${token}` } : {}),
|
|
42
50
|
},
|
|
43
51
|
};
|
|
44
52
|
});
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
const errorLink = this.createErrorLink();
|
|
54
|
+
const link = client_1.ApolloLink.from([errorLink, authLink, httpLink]);
|
|
55
|
+
return new client_1.ApolloClient({
|
|
56
|
+
link,
|
|
57
|
+
cache: new client_1.InMemoryCache(),
|
|
58
|
+
defaultOptions: {
|
|
59
|
+
watchQuery: {
|
|
60
|
+
fetchPolicy: 'network-only',
|
|
61
|
+
errorPolicy: 'all',
|
|
62
|
+
},
|
|
63
|
+
query: {
|
|
64
|
+
fetchPolicy: 'network-only',
|
|
65
|
+
errorPolicy: 'all',
|
|
66
|
+
},
|
|
67
|
+
mutate: {
|
|
68
|
+
errorPolicy: 'all',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
isAuthError(message) {
|
|
74
|
+
const msg = message.toLowerCase();
|
|
75
|
+
return (msg.includes('signature has expired') ||
|
|
76
|
+
msg.includes('error decoding signature') ||
|
|
77
|
+
(msg.includes('token') && msg.includes('expired')) ||
|
|
78
|
+
msg.includes('authentication'));
|
|
79
|
+
}
|
|
80
|
+
createErrorLink() {
|
|
81
|
+
return (0, error_1.onError)(({ graphQLErrors, networkError, operation, forward }) => {
|
|
82
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
83
|
+
// Handle GraphQL-level auth errors
|
|
55
84
|
if (graphQLErrors) {
|
|
56
85
|
const validationErrors = [];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (message.includes('Authentication') ||
|
|
62
|
-
message.includes('Error decoding signature') ||
|
|
63
|
-
message.includes('JWT') ||
|
|
64
|
-
message.toLowerCase().includes('token') ||
|
|
65
|
-
message.includes('Signature has expired')) {
|
|
66
|
-
console.log('==== JWT ERROR DETECTED ====', message);
|
|
67
|
-
// Try to refresh the token if a refresh function is provided
|
|
68
|
-
if (this.options.refreshToken && !this.isRefreshing) {
|
|
69
|
-
this.isRefreshing = true;
|
|
70
|
-
// Attempt to refresh the token
|
|
71
|
-
this.options.refreshToken()
|
|
72
|
-
.then(newToken => {
|
|
73
|
-
var _a, _b;
|
|
74
|
-
if (newToken) {
|
|
75
|
-
console.log('==== TOKEN REFRESHED ====');
|
|
76
|
-
// Set the new token
|
|
77
|
-
this.setToken(newToken);
|
|
78
|
-
// Retry the failed request
|
|
79
|
-
const oldHeaders = operation.getContext().headers;
|
|
80
|
-
operation.setContext({
|
|
81
|
-
headers: {
|
|
82
|
-
...oldHeaders,
|
|
83
|
-
authorization: `JWT ${newToken}`,
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
// Retry the operation
|
|
87
|
-
return forward(operation);
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
console.log('==== TOKEN REFRESH FAILED ====');
|
|
91
|
-
// If refresh token failed, call onUnauthorized
|
|
92
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
.catch(error => {
|
|
96
|
-
var _a, _b;
|
|
97
|
-
console.error('Token refresh failed:', error);
|
|
98
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
99
|
-
})
|
|
100
|
-
.finally(() => {
|
|
101
|
-
this.isRefreshing = false;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
// If no refresh function is provided, just call onUnauthorized
|
|
106
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
107
|
-
}
|
|
86
|
+
let hasAuthError = false;
|
|
87
|
+
graphQLErrors.forEach(({ message }) => {
|
|
88
|
+
if (this.isAuthError(message)) {
|
|
89
|
+
hasAuthError = true;
|
|
108
90
|
}
|
|
109
|
-
//
|
|
110
|
-
if (message.includes('got invalid value') || message.includes('Field') && message.includes('was not provided')) {
|
|
111
|
-
// Extract field name from validation error
|
|
91
|
+
// Collect validation errors
|
|
92
|
+
if (message.includes('got invalid value') || (message.includes('Field') && message.includes('was not provided'))) {
|
|
112
93
|
const fieldMatch = message.match(/Field '([^']+)'/);
|
|
113
|
-
if (fieldMatch
|
|
114
|
-
validationErrors.push({
|
|
115
|
-
field: fieldMatch[1],
|
|
116
|
-
message: message.trim()
|
|
117
|
-
});
|
|
94
|
+
if (fieldMatch === null || fieldMatch === void 0 ? void 0 : fieldMatch[1]) {
|
|
95
|
+
validationErrors.push({ field: fieldMatch[1], message: message.trim() });
|
|
118
96
|
}
|
|
119
97
|
}
|
|
120
98
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
99
|
+
if (validationErrors.length > 0) {
|
|
100
|
+
(_b = (_a = this.options).onValidationError) === null || _b === void 0 ? void 0 : _b.call(_a, validationErrors);
|
|
101
|
+
}
|
|
102
|
+
if (hasAuthError) {
|
|
103
|
+
return this.retryWithRefresh(operation, forward);
|
|
124
104
|
}
|
|
125
105
|
}
|
|
106
|
+
// Handle network-level auth errors
|
|
126
107
|
if (networkError) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (networkError.message && networkError.message.includes('Signature has expired')) {
|
|
130
|
-
console.log('==== JWT NETWORK ERROR DETECTED ====', networkError.message);
|
|
131
|
-
// Try to refresh the token if a refresh function is provided
|
|
132
|
-
if (this.options.refreshToken && !this.isRefreshing) {
|
|
133
|
-
this.isRefreshing = true;
|
|
134
|
-
// Attempt to refresh the token
|
|
135
|
-
this.options.refreshToken()
|
|
136
|
-
.then(newToken => {
|
|
137
|
-
var _a, _b;
|
|
138
|
-
if (newToken) {
|
|
139
|
-
console.log('==== TOKEN REFRESHED (NETWORK) ====');
|
|
140
|
-
// Set the new token
|
|
141
|
-
this.setToken(newToken);
|
|
142
|
-
// Retry the failed request
|
|
143
|
-
const oldHeaders = operation.getContext().headers;
|
|
144
|
-
operation.setContext({
|
|
145
|
-
headers: {
|
|
146
|
-
...oldHeaders,
|
|
147
|
-
authorization: `JWT ${newToken}`,
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
// Retry the operation
|
|
151
|
-
return forward(operation);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
console.log('==== TOKEN REFRESH FAILED (NETWORK) ====');
|
|
155
|
-
// If refresh token failed, call onUnauthorized
|
|
156
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
157
|
-
}
|
|
158
|
-
})
|
|
159
|
-
.catch(error => {
|
|
160
|
-
var _a, _b;
|
|
161
|
-
console.error('Token refresh failed:', error);
|
|
162
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
163
|
-
})
|
|
164
|
-
.finally(() => {
|
|
165
|
-
this.isRefreshing = false;
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
// If no refresh function is provided, just call onUnauthorized
|
|
170
|
-
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
171
|
-
}
|
|
108
|
+
if ((_c = networkError.message) === null || _c === void 0 ? void 0 : _c.includes('Signature has expired')) {
|
|
109
|
+
return this.retryWithRefresh(operation, forward);
|
|
172
110
|
}
|
|
173
|
-
//
|
|
111
|
+
// Extract validation errors from server error responses
|
|
174
112
|
const serverError = networkError;
|
|
175
|
-
if (serverError.name === 'ServerError' &&
|
|
176
|
-
serverError.result &&
|
|
177
|
-
typeof serverError.result === 'object') {
|
|
178
|
-
// Handle validation errors that might be in the response body
|
|
113
|
+
if (serverError.name === 'ServerError' && serverError.result && typeof serverError.result === 'object') {
|
|
179
114
|
try {
|
|
180
115
|
const errorResult = serverError.result;
|
|
181
116
|
if (errorResult.errors && Array.isArray(errorResult.errors)) {
|
|
182
117
|
const validationErrors = [];
|
|
183
118
|
errorResult.errors.forEach((error) => {
|
|
184
|
-
if (error.message && (error.message.includes('got invalid value') ||
|
|
185
|
-
(error.message.includes('Field') && error.message.includes('was not provided')))) {
|
|
119
|
+
if (error.message && (error.message.includes('got invalid value') || (error.message.includes('Field') && error.message.includes('was not provided')))) {
|
|
186
120
|
const fieldMatch = error.message.match(/Field '([^']+)'/);
|
|
187
|
-
if (fieldMatch
|
|
188
|
-
validationErrors.push({
|
|
189
|
-
field: fieldMatch[1],
|
|
190
|
-
message: error.message.trim()
|
|
191
|
-
});
|
|
121
|
+
if (fieldMatch === null || fieldMatch === void 0 ? void 0 : fieldMatch[1]) {
|
|
122
|
+
validationErrors.push({ field: fieldMatch[1], message: error.message.trim() });
|
|
192
123
|
}
|
|
193
124
|
}
|
|
194
125
|
});
|
|
195
|
-
if (validationErrors.length > 0
|
|
196
|
-
this.options.onValidationError(validationErrors);
|
|
126
|
+
if (validationErrors.length > 0) {
|
|
127
|
+
(_e = (_d = this.options).onValidationError) === null || _e === void 0 ? void 0 : _e.call(_d, validationErrors);
|
|
197
128
|
}
|
|
198
129
|
}
|
|
199
130
|
}
|
|
200
|
-
catch (
|
|
201
|
-
|
|
131
|
+
catch (_h) {
|
|
132
|
+
// Ignore parse errors
|
|
202
133
|
}
|
|
203
134
|
}
|
|
204
|
-
(
|
|
135
|
+
(_g = (_f = this.options).onError) === null || _g === void 0 ? void 0 : _g.call(_f, networkError);
|
|
205
136
|
}
|
|
206
137
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Retry a failed operation after refreshing the token.
|
|
141
|
+
* Uses a queue to ensure only ONE refresh HTTP call is made, even when
|
|
142
|
+
* multiple operations fail simultaneously (e.g. expired JWT).
|
|
143
|
+
* The first caller triggers the refresh; subsequent callers queue up
|
|
144
|
+
* and are retried (or errored) when the single refresh completes.
|
|
145
|
+
*/
|
|
146
|
+
retryWithRefresh(operation, forward) {
|
|
147
|
+
return new client_1.Observable((observer) => {
|
|
148
|
+
this.pendingRefreshQueue.push({ operation, forward, observer });
|
|
149
|
+
// If a refresh is already in-flight, this operation is queued and
|
|
150
|
+
// will be retried when that refresh completes. Don't trigger another.
|
|
151
|
+
if (this.isRefreshingLink)
|
|
152
|
+
return;
|
|
153
|
+
this.isRefreshingLink = true;
|
|
154
|
+
const refreshFn = this.options.refreshToken
|
|
155
|
+
? this.options.refreshToken
|
|
156
|
+
: () => this.tokenManager.refresh();
|
|
157
|
+
refreshFn()
|
|
158
|
+
.then((newToken) => {
|
|
159
|
+
var _a, _b, _c, _d;
|
|
160
|
+
if (!newToken) {
|
|
161
|
+
this.drainQueue(null, new Error('Token refresh failed'));
|
|
162
|
+
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
this.options.token = newToken;
|
|
166
|
+
this.tokenManager.setToken(newToken);
|
|
167
|
+
// Legacy refreshToken path bypasses TokenManager._doRefresh() which
|
|
168
|
+
// normally fires onTokenRefreshed. Call it here so consumers (e.g.
|
|
169
|
+
// cf-web persisting to localStorage) still get notified.
|
|
170
|
+
if (this.options.refreshToken) {
|
|
171
|
+
(_d = (_c = this.options).onTokenRefreshed) === null || _d === void 0 ? void 0 : _d.call(_c, newToken);
|
|
172
|
+
}
|
|
173
|
+
this.drainQueue(newToken, null);
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
var _a, _b;
|
|
177
|
+
this.drainQueue(null, error);
|
|
178
|
+
(_b = (_a = this.options).onUnauthorized) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
179
|
+
})
|
|
180
|
+
.finally(() => {
|
|
181
|
+
this.isRefreshingLink = false;
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Drain the pending refresh queue: retry all queued operations with
|
|
187
|
+
* the new token, or error them all if refresh failed.
|
|
188
|
+
*/
|
|
189
|
+
drainQueue(newToken, error) {
|
|
190
|
+
const queue = [...this.pendingRefreshQueue];
|
|
191
|
+
this.pendingRefreshQueue = [];
|
|
192
|
+
if (error || !newToken) {
|
|
193
|
+
queue.forEach(({ observer: obs }) => {
|
|
194
|
+
obs.error(error !== null && error !== void 0 ? error : new Error('Token refresh failed'));
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
queue.forEach(({ operation: op, forward: fwd, observer: obs }) => {
|
|
199
|
+
op.setContext(({ headers = {} }) => ({
|
|
200
|
+
headers: { ...headers, authorization: `JWT ${newToken}` },
|
|
201
|
+
}));
|
|
202
|
+
fwd(op).subscribe(obs);
|
|
226
203
|
});
|
|
227
204
|
}
|
|
228
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Update the JWT token. Does NOT recreate the Apollo client -
|
|
207
|
+
* the auth link reads the token dynamically.
|
|
208
|
+
*/
|
|
229
209
|
setToken(token) {
|
|
230
210
|
this.options.token = token;
|
|
231
|
-
|
|
232
|
-
this.apolloClient = this.createApolloClient();
|
|
211
|
+
this.tokenManager.setToken(token);
|
|
233
212
|
}
|
|
234
|
-
// Get the Apollo client instance
|
|
235
213
|
getClient() {
|
|
236
214
|
return this.apolloClient;
|
|
237
215
|
}
|
|
238
|
-
// Get the base URL
|
|
239
216
|
getBaseUrl() {
|
|
240
217
|
return this.options.baseUrl;
|
|
241
218
|
}
|
|
242
|
-
|
|
219
|
+
/** @deprecated Use tokenManager.refresh() instead */
|
|
243
220
|
async refreshToken() {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.setToken(newToken);
|
|
249
|
-
return true;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
catch (error) {
|
|
253
|
-
console.error('Failed to refresh token:', error);
|
|
254
|
-
}
|
|
221
|
+
const newToken = await this.tokenManager.refresh();
|
|
222
|
+
if (newToken) {
|
|
223
|
+
this.setToken(newToken);
|
|
224
|
+
return true;
|
|
255
225
|
}
|
|
256
226
|
return false;
|
|
257
227
|
}
|
|
258
|
-
|
|
259
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Logout: revoke refresh token server-side, clear Apollo cache, reset state.
|
|
230
|
+
*/
|
|
231
|
+
async logout() {
|
|
232
|
+
await this.tokenManager.logout();
|
|
260
233
|
this.options.token = undefined;
|
|
261
|
-
|
|
262
|
-
|
|
234
|
+
try {
|
|
235
|
+
await this.apolloClient.resetStore();
|
|
236
|
+
}
|
|
237
|
+
catch (_a) {
|
|
238
|
+
// resetStore can throw if there are active queries
|
|
239
|
+
}
|
|
263
240
|
}
|
|
264
241
|
}
|
|
265
242
|
exports.CloudForgeClient = CloudForgeClient;
|
|
@@ -2462,7 +2462,6 @@ export type Login = {
|
|
|
2462
2462
|
id?: Maybe<Scalars['ID']['output']>;
|
|
2463
2463
|
isAdmin?: Maybe<Scalars['Boolean']['output']>;
|
|
2464
2464
|
payload?: Maybe<Scalars['JSONString']['output']>;
|
|
2465
|
-
refreshToken?: Maybe<Scalars['String']['output']>;
|
|
2466
2465
|
token?: Maybe<Scalars['String']['output']>;
|
|
2467
2466
|
};
|
|
2468
2467
|
/** LoginWithGoogle - Login with Google */
|
|
@@ -10493,7 +10492,6 @@ export type LoginMutation = {
|
|
|
10493
10492
|
isAdmin?: boolean | null;
|
|
10494
10493
|
accountId?: string | null;
|
|
10495
10494
|
payload?: any | null;
|
|
10496
|
-
refreshToken?: string | null;
|
|
10497
10495
|
account?: {
|
|
10498
10496
|
__typename?: 'AccountType';
|
|
10499
10497
|
id?: string | null;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CloudForgeSDK = void 0;
|
|
3
|
+
exports.TokenManager = exports.CloudForgeSDK = void 0;
|
|
4
4
|
var sdk_1 = require("./sdk");
|
|
5
5
|
Object.defineProperty(exports, "CloudForgeSDK", { enumerable: true, get: function () { return sdk_1.CloudForgeSDK; } });
|
|
6
|
+
var tokenManager_1 = require("./tokenManager");
|
|
7
|
+
Object.defineProperty(exports, "TokenManager", { enumerable: true, get: function () { return tokenManager_1.TokenManager; } });
|
package/dist/mutations.js
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central token lifecycle manager for CloudForge authentication.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Calling /api/auth/refresh/ with HttpOnly cookie credentials
|
|
6
|
+
* - Promise deduplication (concurrent 401s trigger only one refresh)
|
|
7
|
+
* - BroadcastChannel multi-tab synchronization
|
|
8
|
+
* - authFetch() wrapper for non-GraphQL requests
|
|
9
|
+
* - REST logout endpoint
|
|
10
|
+
*/
|
|
11
|
+
export interface TokenManagerOptions {
|
|
12
|
+
/** GraphQL endpoint URL (e.g. https://api.cloudforgesoftware.com/api/graph/) */
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
/** Called when a new access token is obtained via refresh or tab sync */
|
|
15
|
+
onTokenRefreshed?: (token: string) => void;
|
|
16
|
+
/** Called when auth is irrecoverable (refresh failed, reuse detected, etc.) */
|
|
17
|
+
onSessionExpired?: () => void | Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare class TokenManager {
|
|
20
|
+
private refreshPromise;
|
|
21
|
+
private channel;
|
|
22
|
+
private token;
|
|
23
|
+
private refreshUrl;
|
|
24
|
+
private logoutUrl;
|
|
25
|
+
private options;
|
|
26
|
+
/** Resolve handle to short-circuit an in-flight refresh when another tab broadcasts a token */
|
|
27
|
+
private _broadcastResolve;
|
|
28
|
+
/** AbortController to cancel an in-flight refresh fetch when another tab already refreshed */
|
|
29
|
+
private _refreshAbort;
|
|
30
|
+
constructor(options: TokenManagerOptions);
|
|
31
|
+
getToken(): string | null;
|
|
32
|
+
setToken(token: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Refresh the access token using the HttpOnly refresh cookie.
|
|
35
|
+
* Deduplicates concurrent calls - only one refresh request is ever in flight.
|
|
36
|
+
*/
|
|
37
|
+
refresh(): Promise<string | null>;
|
|
38
|
+
private _doRefresh;
|
|
39
|
+
/**
|
|
40
|
+
* Revoke the refresh token server-side and clear local state.
|
|
41
|
+
*/
|
|
42
|
+
logout(): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Authenticated fetch with automatic token refresh on 401.
|
|
45
|
+
* Drop-in replacement for raw fetch() calls that need JWT auth.
|
|
46
|
+
*/
|
|
47
|
+
authFetch(url: string, options?: RequestInit): Promise<Response>;
|
|
48
|
+
destroy(): void;
|
|
49
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Central token lifecycle manager for CloudForge authentication.
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Calling /api/auth/refresh/ with HttpOnly cookie credentials
|
|
7
|
+
* - Promise deduplication (concurrent 401s trigger only one refresh)
|
|
8
|
+
* - BroadcastChannel multi-tab synchronization
|
|
9
|
+
* - authFetch() wrapper for non-GraphQL requests
|
|
10
|
+
* - REST logout endpoint
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.TokenManager = void 0;
|
|
14
|
+
class TokenManager {
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.refreshPromise = null;
|
|
17
|
+
this.channel = null;
|
|
18
|
+
this.token = null;
|
|
19
|
+
/** Resolve handle to short-circuit an in-flight refresh when another tab broadcasts a token */
|
|
20
|
+
this._broadcastResolve = null;
|
|
21
|
+
/** AbortController to cancel an in-flight refresh fetch when another tab already refreshed */
|
|
22
|
+
this._refreshAbort = null;
|
|
23
|
+
this.options = options;
|
|
24
|
+
// Derive auth endpoints from GraphQL base URL
|
|
25
|
+
const apiBase = options.baseUrl.replace(/\/api\/graph\/?$/, '');
|
|
26
|
+
this.refreshUrl = `${apiBase}/api/auth/refresh/`;
|
|
27
|
+
this.logoutUrl = `${apiBase}/api/auth/logout/`;
|
|
28
|
+
// Multi-tab token synchronization
|
|
29
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
30
|
+
try {
|
|
31
|
+
this.channel = new BroadcastChannel('cf_auth');
|
|
32
|
+
this.channel.onmessage = (event) => {
|
|
33
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
34
|
+
if (((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) === 'TOKEN_REFRESHED' && event.data.token) {
|
|
35
|
+
this.token = event.data.token;
|
|
36
|
+
// Short-circuit any in-flight refresh: abort the HTTP request
|
|
37
|
+
// and resolve the racing promise so we use the broadcast token
|
|
38
|
+
(_b = this._refreshAbort) === null || _b === void 0 ? void 0 : _b.abort();
|
|
39
|
+
(_c = this._broadcastResolve) === null || _c === void 0 ? void 0 : _c.call(this, event.data.token);
|
|
40
|
+
(_e = (_d = this.options).onTokenRefreshed) === null || _e === void 0 ? void 0 : _e.call(_d, event.data.token);
|
|
41
|
+
}
|
|
42
|
+
else if (((_f = event.data) === null || _f === void 0 ? void 0 : _f.type) === 'LOGOUT') {
|
|
43
|
+
this.token = null;
|
|
44
|
+
(_h = (_g = this.options).onSessionExpired) === null || _h === void 0 ? void 0 : _h.call(_g);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (_a) {
|
|
49
|
+
// BroadcastChannel may throw in some environments (e.g. SSR)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
getToken() {
|
|
54
|
+
return this.token;
|
|
55
|
+
}
|
|
56
|
+
setToken(token) {
|
|
57
|
+
this.token = token;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Refresh the access token using the HttpOnly refresh cookie.
|
|
61
|
+
* Deduplicates concurrent calls - only one refresh request is ever in flight.
|
|
62
|
+
*/
|
|
63
|
+
async refresh() {
|
|
64
|
+
if (this.refreshPromise)
|
|
65
|
+
return this.refreshPromise;
|
|
66
|
+
// Race the actual HTTP refresh against a broadcast from another tab.
|
|
67
|
+
// If another tab refreshes first, its broadcast resolves the race
|
|
68
|
+
// immediately and aborts our in-flight fetch.
|
|
69
|
+
const broadcastPromise = new Promise((resolve) => {
|
|
70
|
+
this._broadcastResolve = resolve;
|
|
71
|
+
});
|
|
72
|
+
this.refreshPromise = Promise.race([
|
|
73
|
+
this._doRefresh(),
|
|
74
|
+
broadcastPromise,
|
|
75
|
+
]);
|
|
76
|
+
try {
|
|
77
|
+
return await this.refreshPromise;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.refreshPromise = null;
|
|
81
|
+
this._broadcastResolve = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async _doRefresh() {
|
|
85
|
+
var _a, _b, _c;
|
|
86
|
+
this._refreshAbort = new AbortController();
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(this.refreshUrl, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
credentials: 'include',
|
|
91
|
+
signal: this._refreshAbort.signal,
|
|
92
|
+
});
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
console.error('[CloudForgeSDK] Token refresh failed:', response.status);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const data = await response.json();
|
|
98
|
+
const newToken = data.token;
|
|
99
|
+
if (!newToken || typeof newToken !== 'string') {
|
|
100
|
+
console.error('[CloudForgeSDK] Invalid token in refresh response');
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
this.token = newToken;
|
|
104
|
+
(_a = this.channel) === null || _a === void 0 ? void 0 : _a.postMessage({ type: 'TOKEN_REFRESHED', token: newToken });
|
|
105
|
+
(_c = (_b = this.options).onTokenRefreshed) === null || _c === void 0 ? void 0 : _c.call(_b, newToken);
|
|
106
|
+
return newToken;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
// Aborted because another tab already refreshed - not an error
|
|
110
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
111
|
+
return this.token;
|
|
112
|
+
}
|
|
113
|
+
console.error('[CloudForgeSDK] Refresh error:', error);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
this._refreshAbort = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Revoke the refresh token server-side and clear local state.
|
|
122
|
+
*/
|
|
123
|
+
async logout() {
|
|
124
|
+
var _a, _b;
|
|
125
|
+
// Cancel any in-flight refresh so it can't re-set the token after logout
|
|
126
|
+
(_a = this._refreshAbort) === null || _a === void 0 ? void 0 : _a.abort();
|
|
127
|
+
this._refreshAbort = null;
|
|
128
|
+
this.refreshPromise = null;
|
|
129
|
+
this._broadcastResolve = null;
|
|
130
|
+
try {
|
|
131
|
+
await fetch(this.logoutUrl, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
credentials: 'include',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (_c) {
|
|
137
|
+
// Best-effort - clear local state regardless
|
|
138
|
+
}
|
|
139
|
+
this.token = null;
|
|
140
|
+
(_b = this.channel) === null || _b === void 0 ? void 0 : _b.postMessage({ type: 'LOGOUT' });
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Authenticated fetch with automatic token refresh on 401.
|
|
144
|
+
* Drop-in replacement for raw fetch() calls that need JWT auth.
|
|
145
|
+
*/
|
|
146
|
+
async authFetch(url, options = {}) {
|
|
147
|
+
var _a, _b;
|
|
148
|
+
const headers = new Headers(options.headers);
|
|
149
|
+
if (this.token) {
|
|
150
|
+
headers.set('authorization', `JWT ${this.token}`);
|
|
151
|
+
}
|
|
152
|
+
let response = await fetch(url, { ...options, headers });
|
|
153
|
+
if (response.status === 401) {
|
|
154
|
+
const newToken = await this.refresh();
|
|
155
|
+
if (!newToken) {
|
|
156
|
+
(_b = (_a = this.options).onSessionExpired) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
157
|
+
throw new Error('Session expired');
|
|
158
|
+
}
|
|
159
|
+
headers.set('authorization', `JWT ${newToken}`);
|
|
160
|
+
response = await fetch(url, { ...options, headers });
|
|
161
|
+
}
|
|
162
|
+
return response;
|
|
163
|
+
}
|
|
164
|
+
destroy() {
|
|
165
|
+
var _a, _b;
|
|
166
|
+
(_a = this._refreshAbort) === null || _a === void 0 ? void 0 : _a.abort();
|
|
167
|
+
this._refreshAbort = null;
|
|
168
|
+
this.refreshPromise = null;
|
|
169
|
+
this._broadcastResolve = null;
|
|
170
|
+
(_b = this.channel) === null || _b === void 0 ? void 0 : _b.close();
|
|
171
|
+
this.channel = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
exports.TokenManager = TokenManager;
|