supaapps-auth 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +31 -0
- package/dist/AuthManager.d.ts +12 -9
- package/dist/AuthManager.js +102 -212
- package/jest.config.js +16 -0
- package/package.json +8 -2
- package/src/AuthManager.ts +111 -217
- package/tests/AuthManager.test.ts +107 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Node.js CI
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
build:
|
|
8
|
+
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
node-version: [18.x, 20.x]
|
|
14
|
+
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v3
|
|
18
|
+
- name: Use Node.js ${{ matrix.node-version }}
|
|
19
|
+
uses: actions/setup-node@v3
|
|
20
|
+
with:
|
|
21
|
+
node-version: ${{ matrix.node-version }}
|
|
22
|
+
cache: 'npm'
|
|
23
|
+
- run: npm ci
|
|
24
|
+
- run: npm run build --if-present
|
|
25
|
+
- run: npm run test
|
|
26
|
+
|
|
27
|
+
- name: Upload coverage reports to Codecov
|
|
28
|
+
uses: codecov/codecov-action@v4.0.1
|
|
29
|
+
with:
|
|
30
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
31
|
+
slug: supaapps/supaapps-auth
|
package/dist/AuthManager.d.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
export declare class AuthManager {
|
|
2
2
|
private static instance;
|
|
3
|
-
private
|
|
4
|
-
private
|
|
5
|
-
private
|
|
6
|
-
private
|
|
7
|
-
constructor(
|
|
8
|
-
static
|
|
3
|
+
private authServer;
|
|
4
|
+
private realmName;
|
|
5
|
+
private redirectUri;
|
|
6
|
+
private loginCallback;
|
|
7
|
+
private constructor();
|
|
8
|
+
static initialize(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void): AuthManager;
|
|
9
|
+
static getInstance(): AuthManager;
|
|
9
10
|
private toBase64Url;
|
|
10
11
|
private generatePKCEPair;
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
refreshAccessToken(): Promise<string>;
|
|
13
|
+
checkAccessToken(): Promise<string>;
|
|
14
|
+
private isTokenExpired;
|
|
13
15
|
mustBeLoggedIn(): Promise<boolean>;
|
|
14
16
|
getLoginWithGoogleUri(): string;
|
|
15
17
|
isLoggedIn(): Promise<boolean>;
|
|
16
18
|
getAccessToken(): Promise<string>;
|
|
17
|
-
loginUsingPkce(code:
|
|
19
|
+
loginUsingPkce(code: string): Promise<void>;
|
|
18
20
|
logout(): Promise<void>;
|
|
19
21
|
static validateToken(authServer: string, bearerToken: string): Promise<boolean>;
|
|
22
|
+
static resetInstance(): void;
|
|
20
23
|
}
|
package/dist/AuthManager.js
CHANGED
|
@@ -12,270 +12,160 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.AuthManager = void 0;
|
|
13
13
|
const axios_1 = require("axios");
|
|
14
14
|
const crypto_1 = require("crypto");
|
|
15
|
+
const jsonwebtoken_1 = require("jsonwebtoken"); // Ensure jsonwebtoken is correctly imported
|
|
15
16
|
class AuthManager {
|
|
16
17
|
constructor(authServer, realmName, redirectUri, loginCallback) {
|
|
17
|
-
this.authServer = null;
|
|
18
|
-
this.realmName = null;
|
|
19
|
-
this.redirectUri = null;
|
|
20
|
-
this.loginCallback = () => { };
|
|
21
|
-
this.toBase64Url = (base64String) => {
|
|
22
|
-
return base64String
|
|
23
|
-
.replace(/\+/g, '-')
|
|
24
|
-
.replace(/\//g, '_')
|
|
25
|
-
.replace(/=+$/, '');
|
|
26
|
-
};
|
|
27
|
-
this.generatePKCEPair = () => {
|
|
28
|
-
const NUM_OF_BYTES = 32; // This will generate a verifier of sufficient length
|
|
29
|
-
const HASH_ALG = 'sha256';
|
|
30
|
-
// Generate code verifier
|
|
31
|
-
const newCodeVerifier = this.toBase64Url((0, crypto_1.randomBytes)(NUM_OF_BYTES).toString('base64'));
|
|
32
|
-
// Generate code challenge
|
|
33
|
-
const hash = (0, crypto_1.createHash)(HASH_ALG)
|
|
34
|
-
.update(newCodeVerifier)
|
|
35
|
-
.digest('base64');
|
|
36
|
-
const newCodeChallenge = this.toBase64Url(hash);
|
|
37
|
-
return { newCodeVerifier, newCodeChallenge };
|
|
38
|
-
};
|
|
39
18
|
this.authServer = authServer;
|
|
40
19
|
this.realmName = realmName;
|
|
41
20
|
this.redirectUri = redirectUri;
|
|
42
21
|
this.loginCallback = loginCallback;
|
|
43
22
|
AuthManager.instance = this;
|
|
44
23
|
}
|
|
24
|
+
static initialize(authServer, realmName, redirectUri, loginCallback) {
|
|
25
|
+
if (!AuthManager.instance) {
|
|
26
|
+
AuthManager.instance = new AuthManager(authServer, realmName, redirectUri, loginCallback);
|
|
27
|
+
}
|
|
28
|
+
return AuthManager.instance;
|
|
29
|
+
}
|
|
45
30
|
static getInstance() {
|
|
46
31
|
if (!AuthManager.instance) {
|
|
47
32
|
throw new Error('AuthManager not initialized');
|
|
48
33
|
}
|
|
49
34
|
return AuthManager.instance;
|
|
50
35
|
}
|
|
36
|
+
toBase64Url(base64String) {
|
|
37
|
+
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
38
|
+
}
|
|
39
|
+
generatePKCEPair() {
|
|
40
|
+
var _a, _b;
|
|
41
|
+
const verifier = (_a = localStorage.getItem('codeVerifier')) !== null && _a !== void 0 ? _a : this.toBase64Url((0, crypto_1.randomBytes)(32).toString('base64'));
|
|
42
|
+
const challenge = (_b = localStorage.getItem('codeChallenge')) !== null && _b !== void 0 ? _b : this.toBase64Url((0, crypto_1.createHash)('sha256').update(verifier).digest('base64'));
|
|
43
|
+
localStorage.setItem('codeVerifier', verifier);
|
|
44
|
+
localStorage.setItem('codeChallenge', challenge);
|
|
45
|
+
return { verifier, challenge };
|
|
46
|
+
}
|
|
51
47
|
refreshAccessToken() {
|
|
52
48
|
return __awaiter(this, void 0, void 0, function* () {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
throw new Error('No refresh token found');
|
|
58
|
-
}
|
|
59
|
-
const decodedRefreshToken = JSON.parse(atob(refreshToken.split('.')[1]));
|
|
60
|
-
if (decodedRefreshToken) {
|
|
61
|
-
const currentTime = Date.now() / 1000;
|
|
62
|
-
if (decodedRefreshToken.exp < currentTime) {
|
|
63
|
-
throw new Error('Refresh token expired');
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
yield fetch(`${this.authServer}auth/refresh`, {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: {
|
|
69
|
-
'Content-Type': 'application/json',
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify({
|
|
72
|
-
refresh_token: refreshToken,
|
|
73
|
-
}),
|
|
74
|
-
})
|
|
75
|
-
.then((response) => {
|
|
76
|
-
if (response.status !== 200) {
|
|
77
|
-
throw new Error('Failed to refresh the token');
|
|
78
|
-
}
|
|
79
|
-
return response.json();
|
|
80
|
-
})
|
|
81
|
-
.then((exchangeJson) => {
|
|
82
|
-
localStorage.setItem('refresh_token', exchangeJson.refresh_token);
|
|
83
|
-
localStorage.setItem('access_token', exchangeJson.access_token);
|
|
84
|
-
resolve(exchangeJson.access_token);
|
|
85
|
-
})
|
|
86
|
-
.catch((error) => {
|
|
87
|
-
reject(error);
|
|
88
|
-
});
|
|
49
|
+
try {
|
|
50
|
+
const refreshToken = localStorage.getItem('refresh_token');
|
|
51
|
+
if (!refreshToken) {
|
|
52
|
+
throw new Error('No refresh token found');
|
|
89
53
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
54
|
+
const response = yield axios_1.default.post(`${this.authServer}auth/refresh`, {
|
|
55
|
+
refresh_token: refreshToken
|
|
56
|
+
});
|
|
57
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
58
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
59
|
+
return response.data.access_token;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
console.error(`Refresh token error, logging out: ${error}`);
|
|
63
|
+
localStorage.removeItem('access_token');
|
|
64
|
+
localStorage.removeItem('refresh_token');
|
|
65
|
+
this.loginCallback();
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
94
68
|
});
|
|
95
69
|
}
|
|
96
70
|
checkAccessToken() {
|
|
97
71
|
return __awaiter(this, void 0, void 0, function* () {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
|
|
106
|
-
const currentTime = Date.now() / 1000;
|
|
107
|
-
if (decodedToken && decodedToken.exp < currentTime) {
|
|
108
|
-
accessToken = yield this.refreshAccessToken();
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
resolve(accessToken);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
reject(error);
|
|
115
|
-
}
|
|
116
|
-
}));
|
|
72
|
+
let accessToken = localStorage.getItem('access_token');
|
|
73
|
+
if (!accessToken || this.isTokenExpired(accessToken)) {
|
|
74
|
+
return this.refreshAccessToken();
|
|
75
|
+
}
|
|
76
|
+
return accessToken;
|
|
117
77
|
});
|
|
118
78
|
}
|
|
79
|
+
isTokenExpired(token) {
|
|
80
|
+
const decoded = JSON.parse(atob(token.split('.')[1]));
|
|
81
|
+
return decoded.exp < Date.now() / 1000;
|
|
82
|
+
}
|
|
119
83
|
mustBeLoggedIn() {
|
|
120
84
|
return __awaiter(this, void 0, void 0, function* () {
|
|
121
|
-
return
|
|
122
|
-
this.isLoggedIn().then((isLoggedIn) => {
|
|
123
|
-
if (!isLoggedIn) {
|
|
124
|
-
this.loginCallback();
|
|
125
|
-
return resolve(false);
|
|
126
|
-
}
|
|
127
|
-
return resolve(true);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
85
|
+
return this.isLoggedIn() || (this.loginCallback(), false);
|
|
130
86
|
});
|
|
131
87
|
}
|
|
132
88
|
getLoginWithGoogleUri() {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
let codeVerifier = localStorage.getItem('codeVerifier') || newCodeVerifier;
|
|
136
|
-
let codeChallenge = localStorage.getItem('codeChallenge') || newCodeChallenge;
|
|
137
|
-
localStorage.setItem('codeVerifier', codeVerifier);
|
|
138
|
-
localStorage.setItem('codeChallenge', codeChallenge);
|
|
139
|
-
if (this.authServer && this.realmName && this.redirectUri) {
|
|
140
|
-
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}` +
|
|
141
|
-
`&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
|
|
142
|
-
}
|
|
89
|
+
const { challenge } = this.generatePKCEPair();
|
|
90
|
+
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
143
91
|
}
|
|
144
92
|
isLoggedIn() {
|
|
145
93
|
return __awaiter(this, void 0, void 0, function* () {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
reject(error);
|
|
154
|
-
}
|
|
155
|
-
}));
|
|
94
|
+
try {
|
|
95
|
+
yield this.checkAccessToken();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
156
101
|
});
|
|
157
102
|
}
|
|
158
103
|
getAccessToken() {
|
|
159
104
|
return __awaiter(this, void 0, void 0, function* () {
|
|
160
|
-
|
|
161
|
-
// otherwise throw error
|
|
162
|
-
return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
|
|
163
|
-
try {
|
|
164
|
-
const accessToken = yield this.checkAccessToken();
|
|
165
|
-
return resolve(accessToken);
|
|
166
|
-
}
|
|
167
|
-
catch (error) {
|
|
168
|
-
reject(error);
|
|
169
|
-
}
|
|
170
|
-
}));
|
|
105
|
+
return this.checkAccessToken();
|
|
171
106
|
});
|
|
172
107
|
}
|
|
173
108
|
loginUsingPkce(code) {
|
|
174
109
|
return __awaiter(this, void 0, void 0, function* () {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
fetch(`${this.authServer}auth/pkce_exchange`, {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
headers: {
|
|
182
|
-
'Content-Type': 'application/json',
|
|
183
|
-
},
|
|
184
|
-
body: JSON.stringify({
|
|
185
|
-
realm_name: this.realmName,
|
|
186
|
-
code: code,
|
|
187
|
-
redirect_uri: this.redirectUri,
|
|
188
|
-
code_verifier: codeVerifier,
|
|
189
|
-
}),
|
|
190
|
-
})
|
|
191
|
-
.then((response) => {
|
|
192
|
-
localStorage.removeItem('codeVerifier');
|
|
193
|
-
localStorage.removeItem('codeChallenge');
|
|
194
|
-
if (response.status !== 200) {
|
|
195
|
-
throw new Error('Failed to exchange code for token');
|
|
196
|
-
}
|
|
197
|
-
return response.json();
|
|
198
|
-
})
|
|
199
|
-
.then((exchangeJson) => {
|
|
200
|
-
localStorage.setItem('access_token', exchangeJson.access_token);
|
|
201
|
-
localStorage.setItem('refresh_token', exchangeJson.refresh_token);
|
|
202
|
-
resolve();
|
|
203
|
-
})
|
|
204
|
-
.catch((error) => {
|
|
205
|
-
localStorage.removeItem('codeVerifier');
|
|
206
|
-
localStorage.removeItem('codeChallenge');
|
|
207
|
-
reject(error);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
reject(error);
|
|
110
|
+
try {
|
|
111
|
+
const codeVerifier = localStorage.getItem('codeVerifier');
|
|
112
|
+
if (!codeVerifier) {
|
|
113
|
+
throw new Error('Code verifier not found');
|
|
213
114
|
}
|
|
214
|
-
|
|
115
|
+
const response = yield axios_1.default.post(`${this.authServer}auth/pkce_exchange`, {
|
|
116
|
+
realm_name: this.realmName,
|
|
117
|
+
code,
|
|
118
|
+
redirect_uri: this.redirectUri,
|
|
119
|
+
code_verifier: codeVerifier,
|
|
120
|
+
});
|
|
121
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
122
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
localStorage.removeItem('codeVerifier');
|
|
126
|
+
localStorage.removeItem('codeChallenge');
|
|
127
|
+
}
|
|
215
128
|
});
|
|
216
129
|
}
|
|
217
130
|
logout() {
|
|
218
131
|
return __awaiter(this, void 0, void 0, function* () {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
localStorage.removeItem('refresh_token');
|
|
224
|
-
fetch(`${this.authServer}auth/logout`, {
|
|
225
|
-
method: 'POST',
|
|
226
|
-
headers: {
|
|
227
|
-
'Content-Type': 'application/json',
|
|
228
|
-
'Authorization': `Bearer ${bearerToken}`,
|
|
229
|
-
},
|
|
230
|
-
}).then((response) => {
|
|
231
|
-
if (response.status !== 200) {
|
|
232
|
-
throw new Error('Failed to attempt logout');
|
|
233
|
-
}
|
|
234
|
-
resolve();
|
|
235
|
-
}).catch((error) => {
|
|
236
|
-
reject(error);
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
reject(error);
|
|
132
|
+
try {
|
|
133
|
+
const accessToken = localStorage.getItem('access_token');
|
|
134
|
+
if (!accessToken) {
|
|
135
|
+
throw new Error('Access token not found');
|
|
241
136
|
}
|
|
242
|
-
|
|
137
|
+
yield axios_1.default.post(`${this.authServer}auth/logout`, {}, {
|
|
138
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
localStorage.removeItem('access_token');
|
|
143
|
+
localStorage.removeItem('refresh_token');
|
|
144
|
+
}
|
|
243
145
|
});
|
|
244
146
|
}
|
|
245
147
|
static validateToken(authServer, bearerToken) {
|
|
148
|
+
var _a;
|
|
246
149
|
return __awaiter(this, void 0, void 0, function* () {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!decodedToken) {
|
|
252
|
-
return resolve(false);
|
|
253
|
-
}
|
|
254
|
-
const currentTime = Date.now() / 1000;
|
|
255
|
-
if (decodedToken.exp < currentTime) {
|
|
256
|
-
return resolve(false);
|
|
257
|
-
}
|
|
258
|
-
const { data: publicKey } = yield axios_1.default.get(`${authServer}public/public_key`);
|
|
259
|
-
const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
|
|
260
|
-
const jwt = require('jsonwebtoken');
|
|
261
|
-
jwt.verify(accessToken, publicKey, { algorithms: [algo] }, (error, payload) => {
|
|
262
|
-
if (error) {
|
|
263
|
-
return resolve(false);
|
|
264
|
-
}
|
|
265
|
-
axios_1.default.get(`${authServer}public/revoked_ids`).then(({ data: revokedIds }) => {
|
|
266
|
-
if (revokedIds && revokedIds.includes(decodedToken['id'])) {
|
|
267
|
-
return resolve(false);
|
|
268
|
-
}
|
|
269
|
-
return resolve(true);
|
|
270
|
-
});
|
|
271
|
-
});
|
|
150
|
+
try {
|
|
151
|
+
const decodedToken = (_a = jsonwebtoken_1.default.decode(bearerToken, { complete: true })) === null || _a === void 0 ? void 0 : _a.payload;
|
|
152
|
+
if (!decodedToken || decodedToken.exp < Date.now() / 1000) {
|
|
153
|
+
return false;
|
|
272
154
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
155
|
+
const { data: publicKey } = yield axios_1.default.get(`${authServer}public/public_key`);
|
|
156
|
+
const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
|
|
157
|
+
jsonwebtoken_1.default.verify(bearerToken, publicKey, { algorithms: [algo] });
|
|
158
|
+
const { data: revokedIds } = yield axios_1.default.get(`${authServer}public/revoked_ids`);
|
|
159
|
+
return !revokedIds.includes(decodedToken['id']);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
277
164
|
});
|
|
278
165
|
}
|
|
166
|
+
static resetInstance() {
|
|
167
|
+
AuthManager.instance = null;
|
|
168
|
+
}
|
|
279
169
|
}
|
|
280
170
|
exports.AuthManager = AuthManager;
|
|
281
171
|
AuthManager.instance = null;
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
testEnvironment: 'jsdom', // Use jsdom environment to run tests
|
|
3
|
+
roots: ['<rootDir>/tests'],
|
|
4
|
+
testMatch: ['**/*.test.ts'], // Matches test files in the tests directory
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.tsx?$': 'ts-jest', // Transform TypeScript files using ts-jest
|
|
7
|
+
},
|
|
8
|
+
setupFilesAfterEnv: ['jest-localstorage-mock'], // Setup local storage mock for all tests
|
|
9
|
+
testEnvironment: 'node', // Use node environment to run tests,
|
|
10
|
+
collectCoverage: true,
|
|
11
|
+
coverageReporters: [
|
|
12
|
+
"text",
|
|
13
|
+
"cobertura"
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "supaapps-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "
|
|
8
|
+
"test": "jest",
|
|
9
9
|
"build": "tsc"
|
|
10
10
|
},
|
|
11
11
|
"author": "",
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"jsonwebtoken": "^9.0.2"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
+
"@types/jest": "^29.5.12",
|
|
19
20
|
"@types/node": "^20.11.10",
|
|
21
|
+
"axios-mock-adapter": "^1.22.0",
|
|
22
|
+
"jest": "^29.7.0",
|
|
23
|
+
"jest-localstorage-mock": "^2.4.26",
|
|
24
|
+
"jest-mock-axios": "^4.7.3",
|
|
25
|
+
"ts-jest": "^29.1.2",
|
|
20
26
|
"typescript": "^5.3.3"
|
|
21
27
|
}
|
|
22
28
|
}
|
package/src/AuthManager.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { createHash, randomBytes } from 'crypto';
|
|
3
|
+
import jwt from 'jsonwebtoken'; // Ensure jsonwebtoken is correctly imported
|
|
3
4
|
|
|
4
5
|
export class AuthManager {
|
|
5
6
|
private static instance: AuthManager | null = null;
|
|
6
|
-
private
|
|
7
|
+
private authServer: string;
|
|
8
|
+
private realmName: string;
|
|
9
|
+
private redirectUri: string;
|
|
10
|
+
private loginCallback: () => void;
|
|
7
11
|
|
|
8
|
-
private
|
|
9
|
-
|
|
10
|
-
private readonly redirectUri: string | null = null;
|
|
11
|
-
private readonly loginCallback: () => void = () => {};
|
|
12
|
-
|
|
13
|
-
public constructor(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void) {
|
|
12
|
+
private constructor(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void) {
|
|
14
13
|
this.authServer = authServer;
|
|
15
14
|
this.realmName = realmName;
|
|
16
15
|
this.redirectUri = redirectUri;
|
|
@@ -18,255 +17,150 @@ export class AuthManager {
|
|
|
18
17
|
AuthManager.instance = this;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
public static
|
|
20
|
+
public static initialize(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void): AuthManager {
|
|
21
|
+
if (!AuthManager.instance) {
|
|
22
|
+
AuthManager.instance = new AuthManager(authServer, realmName, redirectUri, loginCallback);
|
|
23
|
+
}
|
|
24
|
+
return AuthManager.instance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public static getInstance(): AuthManager {
|
|
22
28
|
if (!AuthManager.instance) {
|
|
23
29
|
throw new Error('AuthManager not initialized');
|
|
24
30
|
}
|
|
25
31
|
return AuthManager.instance;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
private
|
|
29
|
-
return base64String
|
|
30
|
-
|
|
31
|
-
.replace(/\//g, '_')
|
|
32
|
-
.replace(/=+$/, '');
|
|
33
|
-
};
|
|
34
|
-
private generatePKCEPair = () => {
|
|
35
|
-
const NUM_OF_BYTES = 32; // This will generate a verifier of sufficient length
|
|
36
|
-
const HASH_ALG = 'sha256';
|
|
34
|
+
private toBase64Url(base64String: string): string {
|
|
35
|
+
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
36
|
+
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
);
|
|
38
|
+
private generatePKCEPair(): { verifier: string; challenge: string } {
|
|
39
|
+
const verifier = localStorage.getItem('codeVerifier') ?? this.toBase64Url(randomBytes(32).toString('base64'));
|
|
40
|
+
const challenge = localStorage.getItem('codeChallenge') ?? this.toBase64Url(createHash('sha256').update(verifier).digest('base64'));
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.update(newCodeVerifier)
|
|
46
|
-
.digest('base64');
|
|
47
|
-
const newCodeChallenge = this.toBase64Url(hash);
|
|
42
|
+
localStorage.setItem('codeVerifier', verifier);
|
|
43
|
+
localStorage.setItem('codeChallenge', challenge);
|
|
48
44
|
|
|
49
|
-
return {
|
|
50
|
-
}
|
|
45
|
+
return { verifier, challenge };
|
|
46
|
+
}
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
throw new Error('No refresh token found');
|
|
58
|
-
}
|
|
59
|
-
const decodedRefreshToken = JSON.parse(atob(refreshToken.split('.')[1]));
|
|
60
|
-
if (decodedRefreshToken) {
|
|
61
|
-
const currentTime = Date.now() / 1000;
|
|
62
|
-
if (decodedRefreshToken.exp < currentTime) {
|
|
63
|
-
throw new Error('Refresh token expired');
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
await fetch(`${this.authServer}auth/refresh`, {
|
|
67
|
-
method: 'POST',
|
|
68
|
-
headers: {
|
|
69
|
-
'Content-Type': 'application/json',
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify({
|
|
72
|
-
refresh_token: refreshToken,
|
|
73
|
-
}),
|
|
74
|
-
})
|
|
75
|
-
.then((response) => {
|
|
76
|
-
if (response.status !== 200) {
|
|
77
|
-
throw new Error('Failed to refresh the token');
|
|
78
|
-
}
|
|
79
|
-
return response.json();
|
|
80
|
-
})
|
|
81
|
-
.then((exchangeJson) => {
|
|
82
|
-
localStorage.setItem('refresh_token', exchangeJson.refresh_token);
|
|
83
|
-
localStorage.setItem('access_token', exchangeJson.access_token);
|
|
84
|
-
resolve(exchangeJson.access_token);
|
|
85
|
-
})
|
|
86
|
-
.catch((error) => {
|
|
87
|
-
reject(error);
|
|
88
|
-
});
|
|
89
|
-
} catch (error) {
|
|
90
|
-
reject(error);
|
|
48
|
+
public async refreshAccessToken(): Promise<string> {
|
|
49
|
+
try {
|
|
50
|
+
const refreshToken = localStorage.getItem('refresh_token');
|
|
51
|
+
if (!refreshToken) {
|
|
52
|
+
throw new Error('No refresh token found');
|
|
91
53
|
}
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
54
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let accessToken: string | null = localStorage.getItem('access_token');
|
|
55
|
+
const response = await axios.post(`${this.authServer}auth/refresh`, {
|
|
56
|
+
refresh_token: refreshToken
|
|
57
|
+
});
|
|
99
58
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
59
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
60
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
61
|
+
return response.data.access_token;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`Refresh token error, logging out: ${error}`);
|
|
64
|
+
localStorage.removeItem('access_token');
|
|
65
|
+
localStorage.removeItem('refresh_token');
|
|
66
|
+
this.loginCallback();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
105
70
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
71
|
+
public async checkAccessToken(): Promise<string> {
|
|
72
|
+
let accessToken = localStorage.getItem('access_token');
|
|
73
|
+
if (!accessToken || this.isTokenExpired(accessToken)) {
|
|
74
|
+
return this.refreshAccessToken();
|
|
75
|
+
}
|
|
76
|
+
return accessToken;
|
|
77
|
+
}
|
|
110
78
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
});
|
|
79
|
+
private isTokenExpired(token: string): boolean {
|
|
80
|
+
const decoded = JSON.parse(atob(token.split('.')[1]));
|
|
81
|
+
return decoded.exp < Date.now() / 1000;
|
|
116
82
|
}
|
|
117
83
|
|
|
118
84
|
public async mustBeLoggedIn(): Promise<boolean> {
|
|
119
|
-
return
|
|
120
|
-
this.isLoggedIn().then((isLoggedIn) => {
|
|
121
|
-
if (!isLoggedIn) {
|
|
122
|
-
this.loginCallback();
|
|
123
|
-
return resolve(false);
|
|
124
|
-
}
|
|
125
|
-
return resolve(true);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
85
|
+
return this.isLoggedIn() || (this.loginCallback(), false);
|
|
128
86
|
}
|
|
129
87
|
|
|
130
88
|
public getLoginWithGoogleUri(): string {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
let codeVerifier = localStorage.getItem('codeVerifier') || newCodeVerifier;
|
|
134
|
-
let codeChallenge = localStorage.getItem('codeChallenge') || newCodeChallenge;
|
|
135
|
-
localStorage.setItem('codeVerifier', codeVerifier);
|
|
136
|
-
localStorage.setItem('codeChallenge', codeChallenge);
|
|
137
|
-
|
|
138
|
-
if (this.authServer && this.realmName && this.redirectUri) {
|
|
139
|
-
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}` +
|
|
140
|
-
`&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256`
|
|
141
|
-
}
|
|
89
|
+
const { challenge } = this.generatePKCEPair();
|
|
90
|
+
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
142
91
|
}
|
|
143
92
|
|
|
144
93
|
public async isLoggedIn(): Promise<boolean> {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
reject(error);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
94
|
+
try {
|
|
95
|
+
await this.checkAccessToken();
|
|
96
|
+
return true;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
154
100
|
}
|
|
155
101
|
|
|
156
102
|
public async getAccessToken(): Promise<string> {
|
|
157
|
-
|
|
158
|
-
// otherwise throw error
|
|
159
|
-
return new Promise(async (resolve, reject) => {
|
|
160
|
-
try {
|
|
161
|
-
const accessToken = await this.checkAccessToken();
|
|
162
|
-
|
|
163
|
-
return resolve(accessToken);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
reject(error);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
103
|
+
return this.checkAccessToken();
|
|
168
104
|
}
|
|
169
105
|
|
|
170
|
-
public async loginUsingPkce(code): Promise<void> {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
fetch(`${this.authServer}auth/pkce_exchange`, {
|
|
176
|
-
method: 'POST',
|
|
177
|
-
headers: {
|
|
178
|
-
'Content-Type': 'application/json',
|
|
179
|
-
},
|
|
180
|
-
body: JSON.stringify({
|
|
181
|
-
realm_name: this.realmName,
|
|
182
|
-
code: code,
|
|
183
|
-
redirect_uri: this.redirectUri,
|
|
184
|
-
code_verifier: codeVerifier,
|
|
185
|
-
}),
|
|
186
|
-
})
|
|
187
|
-
.then((response) => {
|
|
188
|
-
localStorage.removeItem('codeVerifier');
|
|
189
|
-
localStorage.removeItem('codeChallenge');
|
|
190
|
-
if (response.status !== 200) {
|
|
191
|
-
throw new Error('Failed to exchange code for token');
|
|
192
|
-
}
|
|
193
|
-
return response.json();
|
|
194
|
-
})
|
|
195
|
-
.then((exchangeJson) => {
|
|
196
|
-
localStorage.setItem('access_token', exchangeJson.access_token);
|
|
197
|
-
localStorage.setItem('refresh_token', exchangeJson.refresh_token);
|
|
198
|
-
resolve();
|
|
199
|
-
})
|
|
200
|
-
.catch((error) => {
|
|
201
|
-
localStorage.removeItem('codeVerifier');
|
|
202
|
-
localStorage.removeItem('codeChallenge');
|
|
203
|
-
reject(error);
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
} catch (error) {
|
|
207
|
-
reject(error);
|
|
106
|
+
public async loginUsingPkce(code: string): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
const codeVerifier = localStorage.getItem('codeVerifier');
|
|
109
|
+
if (!codeVerifier) {
|
|
110
|
+
throw new Error('Code verifier not found');
|
|
208
111
|
}
|
|
209
|
-
|
|
112
|
+
|
|
113
|
+
const response = await axios.post(`${this.authServer}auth/pkce_exchange`, {
|
|
114
|
+
realm_name: this.realmName,
|
|
115
|
+
code,
|
|
116
|
+
redirect_uri: this.redirectUri,
|
|
117
|
+
code_verifier: codeVerifier,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
121
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
122
|
+
} finally {
|
|
123
|
+
localStorage.removeItem('codeVerifier');
|
|
124
|
+
localStorage.removeItem('codeChallenge');
|
|
125
|
+
}
|
|
210
126
|
}
|
|
211
127
|
|
|
212
128
|
public async logout(): Promise<void> {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
localStorage.removeItem('refresh_token');
|
|
218
|
-
fetch(`${this.authServer}auth/logout`, {
|
|
219
|
-
method: 'POST',
|
|
220
|
-
headers: {
|
|
221
|
-
'Content-Type': 'application/json',
|
|
222
|
-
'Authorization': `Bearer ${bearerToken}`,
|
|
223
|
-
},
|
|
224
|
-
}).then((response) => {
|
|
225
|
-
if (response.status !== 200) {
|
|
226
|
-
throw new Error('Failed to attempt logout')
|
|
227
|
-
}
|
|
228
|
-
resolve();
|
|
229
|
-
}).catch((error) => {
|
|
230
|
-
reject(error);
|
|
231
|
-
})
|
|
232
|
-
} catch (error) {
|
|
233
|
-
reject(error);
|
|
129
|
+
try {
|
|
130
|
+
const accessToken = localStorage.getItem('access_token');
|
|
131
|
+
if (!accessToken) {
|
|
132
|
+
throw new Error('Access token not found');
|
|
234
133
|
}
|
|
235
|
-
|
|
134
|
+
await axios.post(`${this.authServer}auth/logout`, {}, {
|
|
135
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
136
|
+
});
|
|
137
|
+
} finally {
|
|
138
|
+
localStorage.removeItem('access_token');
|
|
139
|
+
localStorage.removeItem('refresh_token');
|
|
140
|
+
}
|
|
236
141
|
}
|
|
237
142
|
|
|
238
143
|
public static async validateToken(authServer: string, bearerToken: string): Promise<boolean> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const accessToken = bearerToken.includes('Bearer ') ? bearerToken.replace('Bearer ', '') : bearerToken;
|
|
242
|
-
const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
|
|
144
|
+
try {
|
|
145
|
+
const decodedToken = jwt.decode(bearerToken, { complete: true })?.payload;
|
|
243
146
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
147
|
+
if (!decodedToken || decodedToken.exp < Date.now() / 1000) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
247
150
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return resolve(false);
|
|
251
|
-
}
|
|
151
|
+
const { data: publicKey } = await axios.get(`${authServer}public/public_key`);
|
|
152
|
+
const { data: algo } = await axios.get(`${authServer}public/algo`);
|
|
252
153
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return resolve(true);
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
} catch (error) {
|
|
268
|
-
reject(error);
|
|
269
|
-
}
|
|
270
|
-
})
|
|
154
|
+
jwt.verify(bearerToken, publicKey, { algorithms: [algo] });
|
|
155
|
+
|
|
156
|
+
const { data: revokedIds } = await axios.get(`${authServer}public/revoked_ids`);
|
|
157
|
+
return !revokedIds.includes(decodedToken['id']);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public static resetInstance(): void {
|
|
164
|
+
AuthManager.instance = null;
|
|
271
165
|
}
|
|
272
166
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
3
|
+
import { AuthManager } from '../src/AuthManager';
|
|
4
|
+
|
|
5
|
+
const mock = new MockAdapter(axios);
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
describe('AuthManager Tests', () => {
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
localStorage.clear(); // Clear localStorage before each test
|
|
12
|
+
AuthManager.resetInstance(); // Reset singleton instance
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
it('singleton: should throw when getting instance without initialization', () => {
|
|
18
|
+
expect(() => AuthManager.getInstance()).toThrow('AuthManager not initialized');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('singleton: should create an instance', () => {
|
|
22
|
+
const loginCallback = jest.fn();
|
|
23
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
24
|
+
expect(AuthManager.getInstance()).toBeInstanceOf(AuthManager);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('PKCE Generation: generates a PKCE pair and stores in local storage', () => {
|
|
28
|
+
const loginCallback = jest.fn();
|
|
29
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
30
|
+
// Accessing the private method by casting to any
|
|
31
|
+
const pkce = (manager as any).generatePKCEPair();
|
|
32
|
+
|
|
33
|
+
expect(pkce).toHaveProperty('verifier');
|
|
34
|
+
expect(pkce).toHaveProperty('challenge');
|
|
35
|
+
expect(pkce.verifier).toMatch(/[\w-_]+/);
|
|
36
|
+
expect(pkce.challenge).toMatch(/[\w-_]+/);
|
|
37
|
+
|
|
38
|
+
expect(pkce.verifier).toMatch(/[\w-_=]+/);
|
|
39
|
+
expect(pkce.challenge).toMatch(/[\w-_=]+/);
|
|
40
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('codeVerifier', expect.anything());
|
|
41
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('codeChallenge', expect.anything());
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
it('refreshes access token when expired', async () => {
|
|
46
|
+
mock.onPost('http://auth-server.com/auth/refresh').reply(200, {
|
|
47
|
+
access_token: 'newAccessToken',
|
|
48
|
+
refresh_token: 'newRefreshToken'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const loginCallback = jest.fn();
|
|
52
|
+
// check that we set localstorage correct
|
|
53
|
+
localStorage.setItem('access_token', 'mockAccessToken');
|
|
54
|
+
localStorage.setItem('refresh_token', 'mockRefreshToken');
|
|
55
|
+
|
|
56
|
+
const refresh = localStorage.getItem('refresh_token');
|
|
57
|
+
expect(refresh).toEqual('mockRefreshToken');
|
|
58
|
+
|
|
59
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
60
|
+
const token = await manager.refreshAccessToken();
|
|
61
|
+
|
|
62
|
+
expect(token).toEqual('newAccessToken');
|
|
63
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', 'newAccessToken');
|
|
64
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'newRefreshToken');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
it('throws an error when no refresh token is found', async () => {
|
|
69
|
+
localStorage.removeItem('refresh_token');
|
|
70
|
+
|
|
71
|
+
const loginCallback = jest.fn();
|
|
72
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
73
|
+
|
|
74
|
+
await expect(manager.refreshAccessToken()).rejects.toThrow('No refresh token found');
|
|
75
|
+
await expect(loginCallback).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('logs in using PKCE and updates local storage', async () => {
|
|
79
|
+
localStorage.setItem('codeVerifier', 'mockCodeVerifier');
|
|
80
|
+
mock.onPost('http://auth-server.com/auth/pkce_exchange').reply(200, {
|
|
81
|
+
access_token: 'validAccessToken',
|
|
82
|
+
refresh_token: 'validRefreshToken'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const loginCallback = jest.fn();
|
|
86
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
87
|
+
await manager.loginUsingPkce('mockCode');
|
|
88
|
+
|
|
89
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', 'validAccessToken');
|
|
90
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'validRefreshToken');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('logs out and clears local storage', async () => {
|
|
94
|
+
localStorage.setItem('access_token', 'validAccessToken');
|
|
95
|
+
mock.onPost('http://auth-server.com/auth/logout').reply(200);
|
|
96
|
+
|
|
97
|
+
const loginCallback = jest.fn();
|
|
98
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
99
|
+
await manager.logout();
|
|
100
|
+
|
|
101
|
+
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|
|
102
|
+
expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
});
|