supaapps-auth 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +31 -0
- package/dist/AuthManager.d.ts +13 -9
- package/dist/AuthManager.js +110 -212
- package/jest.config.js +16 -0
- package/package.json +10 -2
- package/src/AuthManager.ts +119 -217
- package/tests/AuthManager.test.ts +120 -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,24 @@
|
|
|
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
|
-
|
|
19
|
+
private saveTokens;
|
|
20
|
+
loginUsingPkce(code: string): Promise<void>;
|
|
18
21
|
logout(): Promise<void>;
|
|
19
22
|
static validateToken(authServer: string, bearerToken: string): Promise<boolean>;
|
|
23
|
+
static resetInstance(): void;
|
|
20
24
|
}
|
package/dist/AuthManager.js
CHANGED
|
@@ -12,270 +12,168 @@ 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
|
+
const user = (0, jsonwebtoken_1.decode)(response.data.access_token);
|
|
60
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
61
|
+
return response.data.access_token;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error(`Refresh token error, logging out: ${error}`);
|
|
65
|
+
localStorage.removeItem('access_token');
|
|
66
|
+
localStorage.removeItem('refresh_token');
|
|
67
|
+
this.loginCallback();
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
94
70
|
});
|
|
95
71
|
}
|
|
96
72
|
checkAccessToken() {
|
|
97
73
|
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
|
-
}));
|
|
74
|
+
let accessToken = localStorage.getItem('access_token');
|
|
75
|
+
if (!accessToken || this.isTokenExpired(accessToken)) {
|
|
76
|
+
return this.refreshAccessToken();
|
|
77
|
+
}
|
|
78
|
+
return accessToken;
|
|
117
79
|
});
|
|
118
80
|
}
|
|
81
|
+
isTokenExpired(token) {
|
|
82
|
+
const decoded = JSON.parse(atob(token.split('.')[1]));
|
|
83
|
+
return decoded.exp < Date.now() / 1000;
|
|
84
|
+
}
|
|
119
85
|
mustBeLoggedIn() {
|
|
120
86
|
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
|
-
});
|
|
87
|
+
return this.isLoggedIn() || (this.loginCallback(), false);
|
|
130
88
|
});
|
|
131
89
|
}
|
|
132
90
|
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
|
-
}
|
|
91
|
+
const { challenge } = this.generatePKCEPair();
|
|
92
|
+
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
143
93
|
}
|
|
144
94
|
isLoggedIn() {
|
|
145
95
|
return __awaiter(this, void 0, void 0, function* () {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
reject(error);
|
|
154
|
-
}
|
|
155
|
-
}));
|
|
96
|
+
try {
|
|
97
|
+
yield this.checkAccessToken();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
156
103
|
});
|
|
157
104
|
}
|
|
158
105
|
getAccessToken() {
|
|
159
106
|
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
|
-
}));
|
|
107
|
+
return this.checkAccessToken();
|
|
171
108
|
});
|
|
172
109
|
}
|
|
110
|
+
saveTokens(response) {
|
|
111
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
112
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
113
|
+
const user = (0, jsonwebtoken_1.decode)(response.data.access_token);
|
|
114
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
115
|
+
}
|
|
173
116
|
loginUsingPkce(code) {
|
|
174
117
|
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
|
-
}
|
|
118
|
+
try {
|
|
119
|
+
const codeVerifier = localStorage.getItem('codeVerifier');
|
|
120
|
+
if (!codeVerifier) {
|
|
121
|
+
throw new Error('Code verifier not found');
|
|
210
122
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
123
|
+
const response = yield axios_1.default.post(`${this.authServer}auth/pkce_exchange`, {
|
|
124
|
+
realm_name: this.realmName,
|
|
125
|
+
code,
|
|
126
|
+
redirect_uri: this.redirectUri,
|
|
127
|
+
code_verifier: codeVerifier,
|
|
128
|
+
});
|
|
129
|
+
this.saveTokens(response);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
localStorage.removeItem('codeVerifier');
|
|
133
|
+
localStorage.removeItem('codeChallenge');
|
|
134
|
+
}
|
|
215
135
|
});
|
|
216
136
|
}
|
|
217
137
|
logout() {
|
|
218
138
|
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);
|
|
139
|
+
try {
|
|
140
|
+
const accessToken = localStorage.getItem('access_token');
|
|
141
|
+
if (!accessToken) {
|
|
142
|
+
throw new Error('Access token not found');
|
|
241
143
|
}
|
|
242
|
-
|
|
144
|
+
yield axios_1.default.post(`${this.authServer}auth/logout`, {}, {
|
|
145
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
localStorage.removeItem('access_token');
|
|
150
|
+
localStorage.removeItem('refresh_token');
|
|
151
|
+
}
|
|
243
152
|
});
|
|
244
153
|
}
|
|
245
154
|
static validateToken(authServer, bearerToken) {
|
|
155
|
+
var _a;
|
|
246
156
|
return __awaiter(this, void 0, void 0, function* () {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
});
|
|
272
|
-
}
|
|
273
|
-
catch (error) {
|
|
274
|
-
reject(error);
|
|
157
|
+
// @todo tests missing for this static validation
|
|
158
|
+
try {
|
|
159
|
+
const decodedToken = (_a = (0, jsonwebtoken_1.decode)(bearerToken, { complete: true })) === null || _a === void 0 ? void 0 : _a.payload;
|
|
160
|
+
if (!decodedToken) {
|
|
161
|
+
return false;
|
|
275
162
|
}
|
|
276
|
-
|
|
163
|
+
const { data: publicKey } = yield axios_1.default.get(`${authServer}public/public_key`);
|
|
164
|
+
const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
|
|
165
|
+
(0, jsonwebtoken_1.verify)(bearerToken, publicKey, { algorithms: [algo] });
|
|
166
|
+
const { data: revokedIds } = yield axios_1.default.get(`${authServer}public/revoked_ids`);
|
|
167
|
+
return !revokedIds.includes(decodedToken['id']);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
277
172
|
});
|
|
278
173
|
}
|
|
174
|
+
static resetInstance() {
|
|
175
|
+
AuthManager.instance = null;
|
|
176
|
+
}
|
|
279
177
|
}
|
|
280
178
|
exports.AuthManager = AuthManager;
|
|
281
179
|
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.1",
|
|
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,15 @@
|
|
|
16
16
|
"jsonwebtoken": "^9.0.2"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
+
"@types/axios": "^0.14.0",
|
|
20
|
+
"@types/jest": "^29.5.12",
|
|
21
|
+
"@types/jsonwebtoken": "^9.0.6",
|
|
19
22
|
"@types/node": "^20.11.10",
|
|
23
|
+
"axios-mock-adapter": "^1.22.0",
|
|
24
|
+
"jest": "^29.7.0",
|
|
25
|
+
"jest-localstorage-mock": "^2.4.26",
|
|
26
|
+
"jest-mock-axios": "^4.7.3",
|
|
27
|
+
"ts-jest": "^29.1.2",
|
|
20
28
|
"typescript": "^5.3.3"
|
|
21
29
|
}
|
|
22
30
|
}
|
package/src/AuthManager.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import axios from 'axios';
|
|
1
|
+
import axios, { AxiosResponse } from 'axios';
|
|
2
2
|
import { createHash, randomBytes } from 'crypto';
|
|
3
|
+
import {decode as jwtDecode, verify as jwtVerify} 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,158 @@ 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
|
+
const user = jwtDecode(response.data.access_token);
|
|
62
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
63
|
+
return response.data.access_token;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Refresh token error, logging out: ${error}`);
|
|
66
|
+
localStorage.removeItem('access_token');
|
|
67
|
+
localStorage.removeItem('refresh_token');
|
|
68
|
+
this.loginCallback();
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
105
72
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
73
|
+
public async checkAccessToken(): Promise<string> {
|
|
74
|
+
let accessToken = localStorage.getItem('access_token');
|
|
75
|
+
if (!accessToken || this.isTokenExpired(accessToken)) {
|
|
76
|
+
return this.refreshAccessToken();
|
|
77
|
+
}
|
|
78
|
+
return accessToken;
|
|
79
|
+
}
|
|
110
80
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
});
|
|
81
|
+
private isTokenExpired(token: string): boolean {
|
|
82
|
+
const decoded = JSON.parse(atob(token.split('.')[1]));
|
|
83
|
+
return decoded.exp < Date.now() / 1000;
|
|
116
84
|
}
|
|
117
85
|
|
|
118
86
|
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
|
-
});
|
|
87
|
+
return this.isLoggedIn() || (this.loginCallback(), false);
|
|
128
88
|
}
|
|
129
89
|
|
|
130
90
|
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
|
-
}
|
|
91
|
+
const { challenge } = this.generatePKCEPair();
|
|
92
|
+
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
142
93
|
}
|
|
143
94
|
|
|
144
95
|
public async isLoggedIn(): Promise<boolean> {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
reject(error);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
96
|
+
try {
|
|
97
|
+
await this.checkAccessToken();
|
|
98
|
+
return true;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
154
102
|
}
|
|
155
103
|
|
|
156
104
|
public async getAccessToken(): Promise<string> {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return new Promise(async (resolve, reject) => {
|
|
160
|
-
try {
|
|
161
|
-
const accessToken = await this.checkAccessToken();
|
|
105
|
+
return this.checkAccessToken();
|
|
106
|
+
}
|
|
162
107
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
108
|
+
private saveTokens(response: AxiosResponse): void {
|
|
109
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
110
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
111
|
+
const user = jwtDecode(response.data.access_token);
|
|
112
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
168
113
|
}
|
|
169
114
|
|
|
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);
|
|
115
|
+
public async loginUsingPkce(code: string): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
const codeVerifier = localStorage.getItem('codeVerifier');
|
|
118
|
+
if (!codeVerifier) {
|
|
119
|
+
throw new Error('Code verifier not found');
|
|
208
120
|
}
|
|
209
|
-
|
|
121
|
+
|
|
122
|
+
const response = await axios.post(`${this.authServer}auth/pkce_exchange`, {
|
|
123
|
+
realm_name: this.realmName,
|
|
124
|
+
code,
|
|
125
|
+
redirect_uri: this.redirectUri,
|
|
126
|
+
code_verifier: codeVerifier,
|
|
127
|
+
});
|
|
128
|
+
this.saveTokens(response);
|
|
129
|
+
} finally {
|
|
130
|
+
localStorage.removeItem('codeVerifier');
|
|
131
|
+
localStorage.removeItem('codeChallenge');
|
|
132
|
+
}
|
|
210
133
|
}
|
|
211
134
|
|
|
212
135
|
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);
|
|
136
|
+
try {
|
|
137
|
+
const accessToken = localStorage.getItem('access_token');
|
|
138
|
+
if (!accessToken) {
|
|
139
|
+
throw new Error('Access token not found');
|
|
234
140
|
}
|
|
235
|
-
|
|
141
|
+
await axios.post(`${this.authServer}auth/logout`, {}, {
|
|
142
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
143
|
+
});
|
|
144
|
+
} finally {
|
|
145
|
+
localStorage.removeItem('access_token');
|
|
146
|
+
localStorage.removeItem('refresh_token');
|
|
147
|
+
}
|
|
236
148
|
}
|
|
237
149
|
|
|
238
150
|
public static async validateToken(authServer: string, bearerToken: string): Promise<boolean> {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
|
|
151
|
+
// @todo tests missing for this static validation
|
|
152
|
+
try {
|
|
153
|
+
const decodedToken = jwtDecode(bearerToken, { complete: true })?.payload;
|
|
243
154
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
155
|
+
if (!decodedToken) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
247
158
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return resolve(false);
|
|
251
|
-
}
|
|
159
|
+
const { data: publicKey } = await axios.get(`${authServer}public/public_key`);
|
|
160
|
+
const { data: algo } = await axios.get(`${authServer}public/algo`);
|
|
252
161
|
|
|
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
|
-
})
|
|
162
|
+
jwtVerify(bearerToken, publicKey, { algorithms: [algo] });
|
|
163
|
+
|
|
164
|
+
const { data: revokedIds } = await axios.get(`${authServer}public/revoked_ids`);
|
|
165
|
+
return !revokedIds.includes(decodedToken['id']);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public static resetInstance(): void {
|
|
172
|
+
AuthManager.instance = null;
|
|
271
173
|
}
|
|
272
174
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
3
|
+
import { AuthManager } from '../src/AuthManager';
|
|
4
|
+
import { basename } from 'path';
|
|
5
|
+
|
|
6
|
+
const mock = new MockAdapter(axios);
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
describe('AuthManager Tests', () => {
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
localStorage.clear(); // Clear localStorage before each test
|
|
13
|
+
AuthManager.resetInstance(); // Reset singleton instance
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
it('singleton: should throw when getting instance without initialization', () => {
|
|
19
|
+
expect(() => AuthManager.getInstance()).toThrow('AuthManager not initialized');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('singleton: should create an instance', () => {
|
|
23
|
+
const loginCallback = jest.fn();
|
|
24
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
25
|
+
expect(AuthManager.getInstance()).toBeInstanceOf(AuthManager);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('PKCE Generation: generates a PKCE pair and stores in local storage', () => {
|
|
29
|
+
const loginCallback = jest.fn();
|
|
30
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
31
|
+
// Accessing the private method by casting to any
|
|
32
|
+
const pkce = (manager as any).generatePKCEPair();
|
|
33
|
+
|
|
34
|
+
expect(pkce).toHaveProperty('verifier');
|
|
35
|
+
expect(pkce).toHaveProperty('challenge');
|
|
36
|
+
expect(pkce.verifier).toMatch(/[\w-_]+/);
|
|
37
|
+
expect(pkce.challenge).toMatch(/[\w-_]+/);
|
|
38
|
+
|
|
39
|
+
expect(pkce.verifier).toMatch(/[\w-_=]+/);
|
|
40
|
+
expect(pkce.challenge).toMatch(/[\w-_=]+/);
|
|
41
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('codeVerifier', expect.anything());
|
|
42
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('codeChallenge', expect.anything());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
it('refreshes access token when expired', async () => {
|
|
47
|
+
mock.onPost('http://auth-server.com/auth/refresh').reply(200, {
|
|
48
|
+
access_token: 'newAccessToken',
|
|
49
|
+
refresh_token: 'newRefreshToken'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const loginCallback = jest.fn();
|
|
53
|
+
// check that we set localstorage correct
|
|
54
|
+
localStorage.setItem('access_token', 'mockAccessToken');
|
|
55
|
+
localStorage.setItem('refresh_token', 'mockRefreshToken');
|
|
56
|
+
|
|
57
|
+
const refresh = localStorage.getItem('refresh_token');
|
|
58
|
+
expect(refresh).toEqual('mockRefreshToken');
|
|
59
|
+
|
|
60
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
61
|
+
const token = await manager.refreshAccessToken();
|
|
62
|
+
|
|
63
|
+
expect(token).toEqual('newAccessToken');
|
|
64
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', 'newAccessToken');
|
|
65
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'newRefreshToken');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
it('throws an error when no refresh token is found', async () => {
|
|
70
|
+
localStorage.removeItem('refresh_token');
|
|
71
|
+
|
|
72
|
+
const loginCallback = jest.fn();
|
|
73
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
74
|
+
|
|
75
|
+
await expect(manager.refreshAccessToken()).rejects.toThrow('No refresh token found');
|
|
76
|
+
await expect(loginCallback).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('logs in using PKCE and updates local storage', async () => {
|
|
80
|
+
localStorage.setItem('codeVerifier', 'mockCodeVerifier');
|
|
81
|
+
/*
|
|
82
|
+
{
|
|
83
|
+
"sub": "1234567890",
|
|
84
|
+
"name": "John Doe",
|
|
85
|
+
"iat": 1516239022
|
|
86
|
+
}
|
|
87
|
+
*/
|
|
88
|
+
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
mock.onPost('http://auth-server.com/auth/pkce_exchange').reply(200, {
|
|
92
|
+
access_token: accessToken,
|
|
93
|
+
refresh_token: 'validRefreshToken'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const loginCallback = jest.fn();
|
|
97
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
98
|
+
await manager.loginUsingPkce('mockCode');
|
|
99
|
+
|
|
100
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', accessToken);
|
|
101
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'validRefreshToken');
|
|
102
|
+
const userSub = JSON.parse(localStorage.getItem('user') ?? '').sub;
|
|
103
|
+
expect(userSub).toEqual('1234567890');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('logs out and clears local storage', async () => {
|
|
107
|
+
localStorage.setItem('access_token', 'validAccessToken');
|
|
108
|
+
mock.onPost('http://auth-server.com/auth/logout').reply(200);
|
|
109
|
+
|
|
110
|
+
const loginCallback = jest.fn();
|
|
111
|
+
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
112
|
+
await manager.logout();
|
|
113
|
+
|
|
114
|
+
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|
|
115
|
+
expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
});
|