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.
@@ -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,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
- 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
- let accessToken = localStorage.getItem('access_token');
101
- if (!accessToken) {
102
- accessToken = yield this.refreshAccessToken();
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 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
  getLoginWithGoogleUri() {
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
- 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
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
147
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
148
- try {
149
- yield this.checkAccessToken();
150
- return resolve(true);
151
- }
152
- catch (error) {
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
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
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
- return new Promise((resolve, reject) => {
176
- try {
177
- const codeVerifier = localStorage.getItem('codeVerifier');
178
- if (codeVerifier) {
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
- return new Promise((resolve, reject) => {
220
- try {
221
- const bearerToken = localStorage.getItem('access_token');
222
- localStorage.removeItem('access_token');
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
- return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
248
- try {
249
- const accessToken = bearerToken.includes('Bearer ') ? bearerToken.replace('Bearer ', '') : bearerToken;
250
- const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
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
- catch (error) {
274
- reject(error);
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.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,255 +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
- });
93
- }
94
54
 
95
- private async checkAccessToken(): Promise<string> {
96
- return new Promise(async (resolve, reject) => {
97
- try {
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
- if (!accessToken) {
101
- accessToken = await this.refreshAccessToken();
102
- } else {
103
- const decodedToken = accessToken ? JSON.parse(atob(accessToken.split('.')[1])) : null;
104
- const currentTime = Date.now() / 1000;
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
- if (decodedToken && decodedToken.exp < currentTime) {
107
- accessToken = await this.refreshAccessToken();
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
- resolve(accessToken);
112
- } catch (error) {
113
- reject(error);
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 new Promise((resolve, reject) => {
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
- // get or create codeVerifier and codeChallenge from localstorage
132
- const { newCodeVerifier, newCodeChallenge } = this.generatePKCEPair();
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
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
146
- return new Promise(async (resolve, reject) => {
147
- try {
148
- await this.checkAccessToken();
149
- return resolve(true);
150
- } catch (error) {
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
- // todo here: check if refresh token is expired and if so, try to refresh, then update token
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
- return new Promise((resolve, reject) => {
172
- try {
173
- const codeVerifier = localStorage.getItem('codeVerifier');
174
- if (codeVerifier) {
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
- return new Promise((resolve, reject) => {
214
- try {
215
- const bearerToken = localStorage.getItem('access_token');
216
- localStorage.removeItem('access_token');
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
- return new Promise<boolean>(async (resolve, reject) => {
240
- try {
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
- if (!decodedToken) {
245
- return resolve(false);
246
- }
147
+ if (!decodedToken || decodedToken.exp < Date.now() / 1000) {
148
+ return false;
149
+ }
247
150
 
248
- const currentTime = Date.now() / 1000;
249
- if (decodedToken.exp < currentTime) {
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
- const { data: publicKey } = await axios.get(`${authServer}public/public_key`);
254
- const { data: algo } = await axios.get(`${authServer}public/algo`);
255
- const jwt = require('jsonwebtoken');
256
- jwt.verify(accessToken, publicKey, { algorithms: [algo] }, (error, payload) => {
257
- if (error) {
258
- return resolve(false);
259
- }
260
- axios.get(`${authServer}public/revoked_ids`).then(({ data: revokedIds }) => {
261
- if (revokedIds && (revokedIds as number[]).includes(decodedToken['id'])) {
262
- return resolve(false);
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
+ });