supaapps-auth 1.2.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.
@@ -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
@@ -1,20 +1,23 @@
1
1
  export declare class AuthManager {
2
2
  private static instance;
3
- private readonly authServer;
4
- private readonly realmName;
5
- private readonly redirectUri;
6
- private readonly loginCallback;
7
- constructor(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void);
8
- static getInstance<T>(): AuthManager;
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
- private refreshAccessToken;
12
- private checkAccessToken;
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: any): Promise<void>;
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
  }
@@ -12,275 +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
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
54
- try {
55
- const refreshToken = localStorage.getItem('refresh_token');
56
- if (!refreshToken) {
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
- catch (error) {
91
- reject(error);
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
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
99
- try {
100
- const accessToken = localStorage.getItem('access_token');
101
- if (!accessToken) {
102
- throw new Error('No access token found');
103
- }
104
- // decode access token and check if it's expired
105
- const decodedToken = accessToken
106
- ? JSON.parse(atob(accessToken.split('.')[1]))
107
- : null;
108
- if (decodedToken) {
109
- const currentTime = Date.now() / 1000;
110
- if (decodedToken.exp < currentTime) {
111
- // refreshing expired token
112
- const newAccessToken = yield this.refreshAccessToken();
113
- return resolve(newAccessToken);
114
- }
115
- }
116
- resolve(accessToken);
117
- }
118
- catch (error) {
119
- reject(error);
120
- }
121
- }));
72
+ let accessToken = localStorage.getItem('access_token');
73
+ if (!accessToken || this.isTokenExpired(accessToken)) {
74
+ return this.refreshAccessToken();
75
+ }
76
+ return accessToken;
122
77
  });
123
78
  }
79
+ isTokenExpired(token) {
80
+ const decoded = JSON.parse(atob(token.split('.')[1]));
81
+ return decoded.exp < Date.now() / 1000;
82
+ }
124
83
  mustBeLoggedIn() {
125
84
  return __awaiter(this, void 0, void 0, function* () {
126
- return new Promise((resolve, reject) => {
127
- this.isLoggedIn().then((isLoggedIn) => {
128
- if (!isLoggedIn) {
129
- this.loginCallback();
130
- return resolve(false);
131
- }
132
- return resolve(true);
133
- });
134
- });
85
+ return this.isLoggedIn() || (this.loginCallback(), false);
135
86
  });
136
87
  }
137
88
  getLoginWithGoogleUri() {
138
- // get or create codeVerifier and codeChallenge from localstorage
139
- const { newCodeVerifier, newCodeChallenge } = this.generatePKCEPair();
140
- let codeVerifier = localStorage.getItem('codeVerifier') || newCodeVerifier;
141
- let codeChallenge = localStorage.getItem('codeChallenge') || newCodeChallenge;
142
- localStorage.setItem('codeVerifier', codeVerifier);
143
- localStorage.setItem('codeChallenge', codeChallenge);
144
- if (this.authServer && this.realmName && this.redirectUri) {
145
- return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}` +
146
- `&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
147
- }
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`;
148
91
  }
149
92
  isLoggedIn() {
150
93
  return __awaiter(this, void 0, void 0, function* () {
151
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
152
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
153
- try {
154
- yield this.checkAccessToken();
155
- return resolve(true);
156
- }
157
- catch (error) {
158
- reject(error);
159
- }
160
- }));
94
+ try {
95
+ yield this.checkAccessToken();
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ return false;
100
+ }
161
101
  });
162
102
  }
163
103
  getAccessToken() {
164
104
  return __awaiter(this, void 0, void 0, function* () {
165
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
166
- // otherwise throw error
167
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
168
- try {
169
- const accessToken = yield this.checkAccessToken();
170
- return resolve(accessToken);
171
- }
172
- catch (error) {
173
- reject(error);
174
- }
175
- }));
105
+ return this.checkAccessToken();
176
106
  });
177
107
  }
178
108
  loginUsingPkce(code) {
179
109
  return __awaiter(this, void 0, void 0, function* () {
180
- return new Promise((resolve, reject) => {
181
- try {
182
- const codeVerifier = localStorage.getItem('codeVerifier');
183
- if (codeVerifier) {
184
- fetch(`${this.authServer}auth/pkce_exchange`, {
185
- method: 'POST',
186
- headers: {
187
- 'Content-Type': 'application/json',
188
- },
189
- body: JSON.stringify({
190
- realm_name: this.realmName,
191
- code: code,
192
- redirect_uri: this.redirectUri,
193
- code_verifier: codeVerifier,
194
- }),
195
- })
196
- .then((response) => {
197
- localStorage.removeItem('codeVerifier');
198
- localStorage.removeItem('codeChallenge');
199
- if (response.status !== 200) {
200
- throw new Error('Failed to exchange code for token');
201
- }
202
- return response.json();
203
- })
204
- .then((exchangeJson) => {
205
- localStorage.setItem('access_token', exchangeJson.access_token);
206
- localStorage.setItem('refresh_token', exchangeJson.refresh_token);
207
- resolve();
208
- })
209
- .catch((error) => {
210
- localStorage.removeItem('codeVerifier');
211
- localStorage.removeItem('codeChallenge');
212
- reject(error);
213
- });
214
- }
215
- }
216
- catch (error) {
217
- reject(error);
110
+ try {
111
+ const codeVerifier = localStorage.getItem('codeVerifier');
112
+ if (!codeVerifier) {
113
+ throw new Error('Code verifier not found');
218
114
  }
219
- });
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
+ }
220
128
  });
221
129
  }
222
130
  logout() {
223
131
  return __awaiter(this, void 0, void 0, function* () {
224
- return new Promise((resolve, reject) => {
225
- try {
226
- const bearerToken = localStorage.getItem('access_token');
227
- localStorage.removeItem('access_token');
228
- localStorage.removeItem('refresh_token');
229
- fetch(`${this.authServer}auth/logout`, {
230
- method: 'POST',
231
- headers: {
232
- 'Content-Type': 'application/json',
233
- 'Authorization': `Bearer ${bearerToken}`,
234
- },
235
- }).then((response) => {
236
- if (response.status !== 200) {
237
- throw new Error('Failed to attempt logout');
238
- }
239
- resolve();
240
- }).catch((error) => {
241
- reject(error);
242
- });
243
- }
244
- catch (error) {
245
- reject(error);
132
+ try {
133
+ const accessToken = localStorage.getItem('access_token');
134
+ if (!accessToken) {
135
+ throw new Error('Access token not found');
246
136
  }
247
- });
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
+ }
248
145
  });
249
146
  }
250
147
  static validateToken(authServer, bearerToken) {
148
+ var _a;
251
149
  return __awaiter(this, void 0, void 0, function* () {
252
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
253
- try {
254
- const accessToken = bearerToken.includes('Bearer ') ? bearerToken.replace('Bearer ', '') : bearerToken;
255
- const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
256
- if (!decodedToken) {
257
- return resolve(false);
258
- }
259
- const currentTime = Date.now() / 1000;
260
- if (decodedToken.exp < currentTime) {
261
- return resolve(false);
262
- }
263
- const { data: publicKey } = yield axios_1.default.get(`${authServer}public/public_key`);
264
- const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
265
- const jwt = require('jsonwebtoken');
266
- jwt.verify(accessToken, publicKey, { algorithms: [algo] }, (error, payload) => {
267
- if (error) {
268
- return resolve(false);
269
- }
270
- axios_1.default.get(`${authServer}public/revoked_ids`).then(({ data: revokedIds }) => {
271
- if (revokedIds && revokedIds.includes(decodedToken['id'])) {
272
- return resolve(false);
273
- }
274
- return resolve(true);
275
- });
276
- });
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;
277
154
  }
278
- catch (error) {
279
- reject(error);
280
- }
281
- }));
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
+ }
282
164
  });
283
165
  }
166
+ static resetInstance() {
167
+ AuthManager.instance = null;
168
+ }
284
169
  }
285
170
  exports.AuthManager = AuthManager;
286
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.2.0",
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": "echo \"Error: no test specified\" && exit 1",
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
  }
@@ -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 readonly authServer: string | null = null;
7
+ private authServer: string;
8
+ private realmName: string;
9
+ private redirectUri: string;
10
+ private loginCallback: () => void;
7
11
 
8
- private readonly realmName: string | null = null;
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,257 +17,150 @@ export class AuthManager {
18
17
  AuthManager.instance = this;
19
18
  }
20
19
 
21
- public static getInstance<T>(): AuthManager{
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 toBase64Url = (base64String: string) => {
29
- return base64String
30
- .replace(/\+/g, '-')
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
- // Generate code verifier
39
- const newCodeVerifier = this.toBase64Url(
40
- randomBytes(NUM_OF_BYTES).toString('base64'),
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
- // Generate code challenge
44
- const hash = createHash(HASH_ALG)
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 { newCodeVerifier, newCodeChallenge };
50
- };
45
+ return { verifier, challenge };
46
+ }
51
47
 
52
- private async refreshAccessToken(): Promise<string> {
53
- return new Promise(async (resolve, reject) => {
54
- try {
55
- const refreshToken: string | null = localStorage.getItem('refresh_token');
56
- if (!refreshToken) {
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
- });
54
+
55
+ const response = await axios.post(`${this.authServer}auth/refresh`, {
56
+ refresh_token: refreshToken
57
+ });
58
+
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
+ }
93
69
  }
94
- private async checkAccessToken(): Promise<string> {
95
- return new Promise(async (resolve, reject) => {
96
- try {
97
- const accessToken: string | null = localStorage.getItem('access_token');
98
- if (!accessToken) {
99
- throw new Error('No access token found');
100
- }
101
- // decode access token and check if it's expired
102
- const decodedToken = accessToken
103
- ? JSON.parse(atob(accessToken.split('.')[1]))
104
- : null;
105
- if (decodedToken) {
106
- const currentTime = Date.now() / 1000;
107
- if (decodedToken.exp < currentTime) {
108
- // refreshing expired token
109
- const newAccessToken = await this.refreshAccessToken();
110
- return resolve(newAccessToken);
111
- }
112
- }
113
- resolve(accessToken);
114
- } catch (error) {
115
- reject(error);
116
- }
117
- })
70
+
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
+ }
78
+
79
+ private isTokenExpired(token: string): boolean {
80
+ const decoded = JSON.parse(atob(token.split('.')[1]));
81
+ return decoded.exp < Date.now() / 1000;
118
82
  }
119
83
 
120
84
  public async mustBeLoggedIn(): Promise<boolean> {
121
- return new Promise((resolve, reject) => {
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
  public getLoginWithGoogleUri(): string {
133
- // get or create codeVerifier and codeChallenge from localstorage
134
- const { newCodeVerifier, newCodeChallenge } = this.generatePKCEPair();
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
-
140
- if (this.authServer && this.realmName && this.redirectUri) {
141
- return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}` +
142
- `&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${codeChallenge}&code_challenge_method=S256`
143
- }
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`;
144
91
  }
145
92
 
146
93
  public async isLoggedIn(): Promise<boolean> {
147
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
148
- return new Promise(async (resolve, reject) => {
149
- try {
150
- await this.checkAccessToken();
151
- return resolve(true);
152
- } catch (error) {
153
- reject(error);
154
- }
155
- });
94
+ try {
95
+ await this.checkAccessToken();
96
+ return true;
97
+ } catch (error) {
98
+ return false;
99
+ }
156
100
  }
157
101
 
158
102
  public async getAccessToken(): Promise<string> {
159
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
160
- // otherwise throw error
161
- return new Promise(async (resolve, reject) => {
162
- try {
163
- const accessToken = await this.checkAccessToken();
164
-
165
- return resolve(accessToken);
166
- } catch (error) {
167
- reject(error);
168
- }
169
- });
103
+ return this.checkAccessToken();
170
104
  }
171
105
 
172
- public async loginUsingPkce(code): Promise<void> {
173
- return new Promise((resolve, reject) => {
174
- try {
175
- const codeVerifier = localStorage.getItem('codeVerifier');
176
- if (codeVerifier) {
177
- fetch(`${this.authServer}auth/pkce_exchange`, {
178
- method: 'POST',
179
- headers: {
180
- 'Content-Type': 'application/json',
181
- },
182
- body: JSON.stringify({
183
- realm_name: this.realmName,
184
- code: code,
185
- redirect_uri: this.redirectUri,
186
- code_verifier: codeVerifier,
187
- }),
188
- })
189
- .then((response) => {
190
- localStorage.removeItem('codeVerifier');
191
- localStorage.removeItem('codeChallenge');
192
- if (response.status !== 200) {
193
- throw new Error('Failed to exchange code for token');
194
- }
195
- return response.json();
196
- })
197
- .then((exchangeJson) => {
198
- localStorage.setItem('access_token', exchangeJson.access_token);
199
- localStorage.setItem('refresh_token', exchangeJson.refresh_token);
200
- resolve();
201
- })
202
- .catch((error) => {
203
- localStorage.removeItem('codeVerifier');
204
- localStorage.removeItem('codeChallenge');
205
- reject(error);
206
- });
207
- }
208
- } catch (error) {
209
- 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');
210
111
  }
211
- });
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
+ }
212
126
  }
213
127
 
214
128
  public async logout(): Promise<void> {
215
- return new Promise((resolve, reject) => {
216
- try {
217
- const bearerToken = localStorage.getItem('access_token');
218
- localStorage.removeItem('access_token');
219
- localStorage.removeItem('refresh_token');
220
- fetch(`${this.authServer}auth/logout`, {
221
- method: 'POST',
222
- headers: {
223
- 'Content-Type': 'application/json',
224
- 'Authorization': `Bearer ${bearerToken}`,
225
- },
226
- }).then((response) => {
227
- if (response.status !== 200) {
228
- throw new Error('Failed to attempt logout')
229
- }
230
- resolve();
231
- }).catch((error) => {
232
- reject(error);
233
- })
234
- } catch (error) {
235
- reject(error);
129
+ try {
130
+ const accessToken = localStorage.getItem('access_token');
131
+ if (!accessToken) {
132
+ throw new Error('Access token not found');
236
133
  }
237
- })
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
+ }
238
141
  }
239
142
 
240
143
  public static async validateToken(authServer: string, bearerToken: string): Promise<boolean> {
241
- return new Promise<boolean>(async (resolve, reject) => {
242
- try {
243
- const accessToken = bearerToken.includes('Bearer ') ? bearerToken.replace('Bearer ', '') : bearerToken;
244
- const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
144
+ try {
145
+ const decodedToken = jwt.decode(bearerToken, { complete: true })?.payload;
245
146
 
246
- if (!decodedToken) {
247
- return resolve(false);
248
- }
147
+ if (!decodedToken || decodedToken.exp < Date.now() / 1000) {
148
+ return false;
149
+ }
249
150
 
250
- const currentTime = Date.now() / 1000;
251
- if (decodedToken.exp < currentTime) {
252
- return resolve(false);
253
- }
151
+ const { data: publicKey } = await axios.get(`${authServer}public/public_key`);
152
+ const { data: algo } = await axios.get(`${authServer}public/algo`);
254
153
 
255
- const { data: publicKey } = await axios.get(`${authServer}public/public_key`);
256
- const { data: algo } = await axios.get(`${authServer}public/algo`);
257
- const jwt = require('jsonwebtoken');
258
- jwt.verify(accessToken, publicKey, { algorithms: [algo] }, (error, payload) => {
259
- if (error) {
260
- return resolve(false);
261
- }
262
- axios.get(`${authServer}public/revoked_ids`).then(({ data: revokedIds }) => {
263
- if (revokedIds && (revokedIds as number[]).includes(decodedToken['id'])) {
264
- return resolve(false);
265
- }
266
- return resolve(true);
267
- });
268
- });
269
- } catch (error) {
270
- reject(error);
271
- }
272
- })
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;
273
165
  }
274
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
+ });