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.
@@ -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,24 @@
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
+ 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
  }
@@ -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
- 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
+ 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
- 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
- }));
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 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
- });
87
+ return this.isLoggedIn() || (this.loginCallback(), false);
130
88
  });
131
89
  }
132
90
  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
- }
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
- // 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
- }));
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
- // 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
- }));
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
- 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
- }
118
+ try {
119
+ const codeVerifier = localStorage.getItem('codeVerifier');
120
+ if (!codeVerifier) {
121
+ throw new Error('Code verifier not found');
210
122
  }
211
- catch (error) {
212
- reject(error);
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
- 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);
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
- 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
- });
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.0",
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": "echo \"Error: no test specified\" && exit 1",
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
  }
@@ -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 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,158 @@ 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
+ 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
- if (decodedToken && decodedToken.exp < currentTime) {
107
- accessToken = await this.refreshAccessToken();
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
- resolve(accessToken);
112
- } catch (error) {
113
- reject(error);
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 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
- });
87
+ return this.isLoggedIn() || (this.loginCallback(), false);
128
88
  }
129
89
 
130
90
  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
- }
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
- // 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
- });
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
- // 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();
105
+ return this.checkAccessToken();
106
+ }
162
107
 
163
- return resolve(accessToken);
164
- } catch (error) {
165
- reject(error);
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
- 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);
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
- 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);
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
- 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;
151
+ // @todo tests missing for this static validation
152
+ try {
153
+ const decodedToken = jwtDecode(bearerToken, { complete: true })?.payload;
243
154
 
244
- if (!decodedToken) {
245
- return resolve(false);
246
- }
155
+ if (!decodedToken) {
156
+ return false;
157
+ }
247
158
 
248
- const currentTime = Date.now() / 1000;
249
- if (decodedToken.exp < currentTime) {
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
- 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
- })
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
+ });