supaapps-auth 1.4.1 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +3 -0
- package/.github/workflows/ci.yml +1 -0
- package/.prettierrc +7 -0
- package/dist/AuthManager.d.ts +6 -4
- package/dist/AuthManager.js +60 -23
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +13 -0
- package/package.json +16 -4
- package/src/AuthManager.ts +226 -137
- package/src/index.ts +1 -0
- package/src/types.ts +28 -0
- package/tests/AuthManager.test.ts +15 -7
package/.eslintrc
ADDED
package/.github/workflows/ci.yml
CHANGED
package/.prettierrc
ADDED
package/dist/AuthManager.d.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
|
+
import { AuthManagerEvent } from './types';
|
|
1
2
|
export declare class AuthManager {
|
|
2
3
|
private static instance;
|
|
3
4
|
private authServer;
|
|
4
5
|
private realmName;
|
|
5
6
|
private redirectUri;
|
|
6
|
-
private
|
|
7
|
+
private onStateChange;
|
|
7
8
|
private constructor();
|
|
8
|
-
static initialize(authServer: string, realmName: string, redirectUri: string,
|
|
9
|
+
static initialize(authServer: string, realmName: string, redirectUri: string, onStateChange: (event: AuthManagerEvent) => void): AuthManager;
|
|
9
10
|
static getInstance(): AuthManager;
|
|
11
|
+
private tokenToPayload;
|
|
10
12
|
private toBase64Url;
|
|
11
13
|
private generatePKCEPair;
|
|
12
14
|
refreshAccessToken(): Promise<string>;
|
|
13
15
|
checkAccessToken(): Promise<string>;
|
|
14
16
|
private isTokenExpired;
|
|
15
|
-
mustBeLoggedIn(): Promise<
|
|
17
|
+
mustBeLoggedIn(): Promise<void>;
|
|
16
18
|
getLoginWithGoogleUri(): string;
|
|
17
19
|
isLoggedIn(): Promise<boolean>;
|
|
18
|
-
getAccessToken(): Promise<string>;
|
|
20
|
+
getAccessToken(mustBeLoggedIn?: boolean): Promise<string>;
|
|
19
21
|
private saveTokens;
|
|
20
22
|
loginUsingPkce(code: string): Promise<void>;
|
|
21
23
|
logout(): Promise<void>;
|
package/dist/AuthManager.js
CHANGED
|
@@ -13,17 +13,29 @@ exports.AuthManager = void 0;
|
|
|
13
13
|
const axios_1 = require("axios");
|
|
14
14
|
const crypto_1 = require("crypto");
|
|
15
15
|
const jsonwebtoken_1 = require("jsonwebtoken"); // Ensure jsonwebtoken is correctly imported
|
|
16
|
+
const types_1 = require("./types");
|
|
16
17
|
class AuthManager {
|
|
17
|
-
constructor(authServer, realmName, redirectUri,
|
|
18
|
+
constructor(authServer, realmName, redirectUri, onStateChange) {
|
|
18
19
|
this.authServer = authServer;
|
|
19
20
|
this.realmName = realmName;
|
|
20
21
|
this.redirectUri = redirectUri;
|
|
21
|
-
this.
|
|
22
|
+
this.onStateChange = onStateChange;
|
|
22
23
|
AuthManager.instance = this;
|
|
23
24
|
}
|
|
24
|
-
static initialize(authServer, realmName, redirectUri,
|
|
25
|
+
static initialize(authServer, realmName, redirectUri, onStateChange) {
|
|
25
26
|
if (!AuthManager.instance) {
|
|
26
|
-
AuthManager.instance = new AuthManager(authServer, realmName, redirectUri,
|
|
27
|
+
AuthManager.instance = new AuthManager(authServer, realmName, redirectUri, onStateChange);
|
|
28
|
+
AuthManager.instance
|
|
29
|
+
.checkAccessToken()
|
|
30
|
+
.then((token) => {
|
|
31
|
+
onStateChange({
|
|
32
|
+
type: types_1.AuthEventType.INITALIZED_IN,
|
|
33
|
+
user: AuthManager.instance.tokenToPayload(token),
|
|
34
|
+
});
|
|
35
|
+
})
|
|
36
|
+
.catch(() => {
|
|
37
|
+
onStateChange({ type: types_1.AuthEventType.INITALIZED_OUT });
|
|
38
|
+
});
|
|
27
39
|
}
|
|
28
40
|
return AuthManager.instance;
|
|
29
41
|
}
|
|
@@ -33,8 +45,14 @@ class AuthManager {
|
|
|
33
45
|
}
|
|
34
46
|
return AuthManager.instance;
|
|
35
47
|
}
|
|
48
|
+
tokenToPayload(token) {
|
|
49
|
+
return JSON.parse(atob(token.split('.')[1]));
|
|
50
|
+
}
|
|
36
51
|
toBase64Url(base64String) {
|
|
37
|
-
return base64String
|
|
52
|
+
return base64String
|
|
53
|
+
.replace(/\+/g, '-')
|
|
54
|
+
.replace(/\//g, '_')
|
|
55
|
+
.replace(/=+$/, '');
|
|
38
56
|
}
|
|
39
57
|
generatePKCEPair() {
|
|
40
58
|
var _a, _b;
|
|
@@ -52,39 +70,40 @@ class AuthManager {
|
|
|
52
70
|
throw new Error('No refresh token found');
|
|
53
71
|
}
|
|
54
72
|
const response = yield axios_1.default.post(`${this.authServer}auth/refresh`, {
|
|
55
|
-
refresh_token: refreshToken
|
|
73
|
+
refresh_token: refreshToken,
|
|
56
74
|
});
|
|
57
|
-
|
|
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));
|
|
75
|
+
this.saveTokens(response, true);
|
|
61
76
|
return response.data.access_token;
|
|
62
77
|
}
|
|
63
78
|
catch (error) {
|
|
64
79
|
console.error(`Refresh token error, logging out: ${error}`);
|
|
65
80
|
localStorage.removeItem('access_token');
|
|
66
81
|
localStorage.removeItem('refresh_token');
|
|
67
|
-
this.
|
|
82
|
+
this.onStateChange({ type: types_1.AuthEventType.REFRESH_FAILED });
|
|
68
83
|
throw error;
|
|
69
84
|
}
|
|
70
85
|
});
|
|
71
86
|
}
|
|
72
87
|
checkAccessToken() {
|
|
73
88
|
return __awaiter(this, void 0, void 0, function* () {
|
|
74
|
-
|
|
75
|
-
if (
|
|
89
|
+
const accessToken = localStorage.getItem('access_token');
|
|
90
|
+
if (accessToken || this.isTokenExpired(accessToken)) {
|
|
76
91
|
return this.refreshAccessToken();
|
|
77
92
|
}
|
|
78
93
|
return accessToken;
|
|
79
94
|
});
|
|
80
95
|
}
|
|
81
96
|
isTokenExpired(token) {
|
|
82
|
-
const decoded =
|
|
97
|
+
const decoded = this.tokenToPayload(token);
|
|
83
98
|
return decoded.exp < Date.now() / 1000;
|
|
84
99
|
}
|
|
85
100
|
mustBeLoggedIn() {
|
|
86
101
|
return __awaiter(this, void 0, void 0, function* () {
|
|
87
|
-
|
|
102
|
+
if (!(yield this.isLoggedIn())) {
|
|
103
|
+
this.onStateChange({
|
|
104
|
+
type: types_1.AuthEventType.FAILED_MUST_LOGIN_CHECK,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
88
107
|
});
|
|
89
108
|
}
|
|
90
109
|
getLoginWithGoogleUri() {
|
|
@@ -103,14 +122,28 @@ class AuthManager {
|
|
|
103
122
|
});
|
|
104
123
|
}
|
|
105
124
|
getAccessToken() {
|
|
106
|
-
return __awaiter(this,
|
|
107
|
-
|
|
125
|
+
return __awaiter(this, arguments, void 0, function* (mustBeLoggedIn = false) {
|
|
126
|
+
try {
|
|
127
|
+
return yield this.checkAccessToken();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (mustBeLoggedIn) {
|
|
131
|
+
this.onStateChange({
|
|
132
|
+
type: types_1.AuthEventType.FAILED_MUST_LOGIN_CHECK,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
108
137
|
});
|
|
109
138
|
}
|
|
110
|
-
saveTokens(response) {
|
|
139
|
+
saveTokens(response, byRefresh) {
|
|
111
140
|
localStorage.setItem('access_token', response.data.access_token);
|
|
112
141
|
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
113
|
-
|
|
142
|
+
this.onStateChange({
|
|
143
|
+
type: byRefresh ? types_1.AuthEventType.USER_UPDATED : types_1.AuthEventType.USER_LOGGED_IN,
|
|
144
|
+
user: this.tokenToPayload(response.data.access_token),
|
|
145
|
+
});
|
|
146
|
+
const user = this.tokenToPayload(response.data.access_token);
|
|
114
147
|
localStorage.setItem('user', JSON.stringify(user));
|
|
115
148
|
}
|
|
116
149
|
loginUsingPkce(code) {
|
|
@@ -126,7 +159,7 @@ class AuthManager {
|
|
|
126
159
|
redirect_uri: this.redirectUri,
|
|
127
160
|
code_verifier: codeVerifier,
|
|
128
161
|
});
|
|
129
|
-
this.saveTokens(response);
|
|
162
|
+
this.saveTokens(response, false);
|
|
130
163
|
}
|
|
131
164
|
finally {
|
|
132
165
|
localStorage.removeItem('codeVerifier');
|
|
@@ -142,21 +175,24 @@ class AuthManager {
|
|
|
142
175
|
throw new Error('Access token not found');
|
|
143
176
|
}
|
|
144
177
|
yield axios_1.default.post(`${this.authServer}auth/logout`, {}, {
|
|
145
|
-
headers: { Authorization: `Bearer ${accessToken}` }
|
|
178
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
146
179
|
});
|
|
147
180
|
}
|
|
148
181
|
finally {
|
|
149
182
|
localStorage.removeItem('access_token');
|
|
150
183
|
localStorage.removeItem('refresh_token');
|
|
184
|
+
this.onStateChange({ type: types_1.AuthEventType.USER_LOGGED_OUT });
|
|
151
185
|
}
|
|
152
186
|
});
|
|
153
187
|
}
|
|
154
188
|
static validateToken(authServer, bearerToken) {
|
|
155
|
-
var _a;
|
|
156
189
|
return __awaiter(this, void 0, void 0, function* () {
|
|
190
|
+
var _a;
|
|
157
191
|
// @todo tests missing for this static validation
|
|
158
192
|
try {
|
|
159
|
-
const decodedToken = (_a = (0, jsonwebtoken_1.decode)(bearerToken, {
|
|
193
|
+
const decodedToken = (_a = (0, jsonwebtoken_1.decode)(bearerToken, {
|
|
194
|
+
complete: true,
|
|
195
|
+
})) === null || _a === void 0 ? void 0 : _a.payload;
|
|
160
196
|
if (!decodedToken) {
|
|
161
197
|
return false;
|
|
162
198
|
}
|
|
@@ -164,6 +200,7 @@ class AuthManager {
|
|
|
164
200
|
const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
|
|
165
201
|
(0, jsonwebtoken_1.verify)(bearerToken, publicKey, { algorithms: [algo] });
|
|
166
202
|
const { data: revokedIds } = yield axios_1.default.get(`${authServer}public/revoked_ids`);
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/dot-notation
|
|
167
204
|
return !revokedIds.includes(decodedToken['id']);
|
|
168
205
|
}
|
|
169
206
|
catch (error) {
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
2
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
17
|
exports.AuthManager = void 0;
|
|
18
|
+
__exportStar(require("./types"), exports);
|
|
4
19
|
var AuthManager_1 = require("./AuthManager");
|
|
5
20
|
Object.defineProperty(exports, "AuthManager", { enumerable: true, get: function () { return AuthManager_1.AuthManager; } });
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare enum AuthEventType {
|
|
2
|
+
INITALIZED_IN = "initialized-logged-in",
|
|
3
|
+
INITALIZED_OUT = "initialized-logged-out",
|
|
4
|
+
USER_LOGGED_IN = "user-logged-in",
|
|
5
|
+
USER_LOGGED_OUT = "user-logged-out",
|
|
6
|
+
USER_UPDATED = "user-updated",
|
|
7
|
+
FAILED_MUST_LOGIN_CHECK = "failed-must-login",
|
|
8
|
+
REFRESH_FAILED = "refresh-failed"
|
|
9
|
+
}
|
|
10
|
+
export interface UserTokenPayload {
|
|
11
|
+
id: number;
|
|
12
|
+
iss: string;
|
|
13
|
+
sub: number;
|
|
14
|
+
first_name: string;
|
|
15
|
+
last_name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
aud: string;
|
|
18
|
+
iat: number;
|
|
19
|
+
exp: number;
|
|
20
|
+
scopes: string;
|
|
21
|
+
}
|
|
22
|
+
export interface AuthManagerEvent {
|
|
23
|
+
type: AuthEventType;
|
|
24
|
+
user?: UserTokenPayload;
|
|
25
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuthEventType = void 0;
|
|
4
|
+
var AuthEventType;
|
|
5
|
+
(function (AuthEventType) {
|
|
6
|
+
AuthEventType["INITALIZED_IN"] = "initialized-logged-in";
|
|
7
|
+
AuthEventType["INITALIZED_OUT"] = "initialized-logged-out";
|
|
8
|
+
AuthEventType["USER_LOGGED_IN"] = "user-logged-in";
|
|
9
|
+
AuthEventType["USER_LOGGED_OUT"] = "user-logged-out";
|
|
10
|
+
AuthEventType["USER_UPDATED"] = "user-updated";
|
|
11
|
+
AuthEventType["FAILED_MUST_LOGIN_CHECK"] = "failed-must-login";
|
|
12
|
+
AuthEventType["REFRESH_FAILED"] = "refresh-failed";
|
|
13
|
+
})(AuthEventType || (exports.AuthEventType = AuthEventType = {}));
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "supaapps-auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-rc.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest",
|
|
9
|
-
"build": "tsc"
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"lint": "eslint src/ --ext .ts,.tsx"
|
|
10
11
|
},
|
|
11
12
|
"author": "",
|
|
12
13
|
"license": "MIT",
|
|
@@ -16,15 +17,26 @@
|
|
|
16
17
|
"jsonwebtoken": "^9.0.2"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@next/eslint-plugin-next": "^13.5.6",
|
|
19
21
|
"@types/axios": "^0.14.0",
|
|
20
22
|
"@types/jest": "^29.5.12",
|
|
21
23
|
"@types/jsonwebtoken": "^9.0.6",
|
|
22
|
-
"@types/node": "^20.
|
|
24
|
+
"@types/node": "^20.12.12",
|
|
25
|
+
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
26
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
23
27
|
"axios-mock-adapter": "^1.22.0",
|
|
28
|
+
"eslint": "^8.57.0",
|
|
29
|
+
"eslint-config-airbnb-base": "^15.0.0",
|
|
30
|
+
"eslint-config-airbnb-typescript": "^17.1.0",
|
|
31
|
+
"eslint-config-next": "^13.5.6",
|
|
32
|
+
"eslint-config-prettier": "^9.1.0",
|
|
33
|
+
"eslint-config-supaapps": "^1.1.0",
|
|
34
|
+
"eslint-plugin-import": "^2.29.1",
|
|
24
35
|
"jest": "^29.7.0",
|
|
25
36
|
"jest-localstorage-mock": "^2.4.26",
|
|
26
37
|
"jest-mock-axios": "^4.7.3",
|
|
27
38
|
"ts-jest": "^29.1.2",
|
|
28
|
-
"typescript": "^5.3.3"
|
|
39
|
+
"typescript": "^5.3.3",
|
|
40
|
+
"undefined": "^0.1.0"
|
|
29
41
|
}
|
|
30
42
|
}
|
package/src/AuthManager.ts
CHANGED
|
@@ -1,174 +1,263 @@
|
|
|
1
1
|
import axios, { AxiosResponse } from 'axios';
|
|
2
2
|
import { createHash, randomBytes } from 'crypto';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
decode as jwtDecode,
|
|
5
|
+
verify as jwtVerify,
|
|
6
|
+
} from 'jsonwebtoken'; // Ensure jsonwebtoken is correctly imported
|
|
7
|
+
import { AuthEventType, AuthManagerEvent, UserTokenPayload } from './types';
|
|
4
8
|
|
|
5
9
|
export class AuthManager {
|
|
6
|
-
|
|
7
|
-
private authServer: string;
|
|
8
|
-
private realmName: string;
|
|
9
|
-
private redirectUri: string;
|
|
10
|
-
private loginCallback: () => void;
|
|
11
|
-
|
|
12
|
-
private constructor(authServer: string, realmName: string, redirectUri: string, loginCallback: () => void) {
|
|
13
|
-
this.authServer = authServer;
|
|
14
|
-
this.realmName = realmName;
|
|
15
|
-
this.redirectUri = redirectUri;
|
|
16
|
-
this.loginCallback = loginCallback;
|
|
17
|
-
AuthManager.instance = this;
|
|
18
|
-
}
|
|
10
|
+
private static instance: AuthManager | null = null;
|
|
19
11
|
|
|
20
|
-
|
|
21
|
-
if (!AuthManager.instance) {
|
|
22
|
-
AuthManager.instance = new AuthManager(authServer, realmName, redirectUri, loginCallback);
|
|
23
|
-
}
|
|
24
|
-
return AuthManager.instance;
|
|
25
|
-
}
|
|
12
|
+
private authServer: string;
|
|
26
13
|
|
|
27
|
-
|
|
28
|
-
if (!AuthManager.instance) {
|
|
29
|
-
throw new Error('AuthManager not initialized');
|
|
30
|
-
}
|
|
31
|
-
return AuthManager.instance;
|
|
32
|
-
}
|
|
14
|
+
private realmName: string;
|
|
33
15
|
|
|
34
|
-
|
|
35
|
-
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
36
|
-
}
|
|
16
|
+
private redirectUri: string;
|
|
37
17
|
|
|
38
|
-
|
|
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'));
|
|
18
|
+
private onStateChange: (event: AuthManagerEvent) => void;
|
|
41
19
|
|
|
42
|
-
|
|
43
|
-
|
|
20
|
+
private constructor(
|
|
21
|
+
authServer: string,
|
|
22
|
+
realmName: string,
|
|
23
|
+
redirectUri: string,
|
|
24
|
+
onStateChange: (event: AuthManagerEvent) => void,
|
|
25
|
+
) {
|
|
26
|
+
this.authServer = authServer;
|
|
27
|
+
this.realmName = realmName;
|
|
28
|
+
this.redirectUri = redirectUri;
|
|
29
|
+
this.onStateChange = onStateChange;
|
|
30
|
+
AuthManager.instance = this;
|
|
31
|
+
}
|
|
44
32
|
|
|
45
|
-
|
|
33
|
+
public static initialize(
|
|
34
|
+
authServer: string,
|
|
35
|
+
realmName: string,
|
|
36
|
+
redirectUri: string,
|
|
37
|
+
onStateChange: (event: AuthManagerEvent) => void,
|
|
38
|
+
): AuthManager {
|
|
39
|
+
if (!AuthManager.instance) {
|
|
40
|
+
AuthManager.instance = new AuthManager(
|
|
41
|
+
authServer,
|
|
42
|
+
realmName,
|
|
43
|
+
redirectUri,
|
|
44
|
+
onStateChange,
|
|
45
|
+
);
|
|
46
|
+
AuthManager.instance
|
|
47
|
+
.checkAccessToken()
|
|
48
|
+
.then((token) => {
|
|
49
|
+
onStateChange({
|
|
50
|
+
type: AuthEventType.INITALIZED_IN,
|
|
51
|
+
user: AuthManager.instance.tokenToPayload(token),
|
|
52
|
+
});
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {
|
|
55
|
+
onStateChange({ type: AuthEventType.INITALIZED_OUT });
|
|
56
|
+
});
|
|
46
57
|
}
|
|
58
|
+
return AuthManager.instance;
|
|
59
|
+
}
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!refreshToken) {
|
|
52
|
-
throw new Error('No refresh token found');
|
|
53
|
-
}
|
|
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
|
-
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
|
-
}
|
|
61
|
+
public static getInstance(): AuthManager {
|
|
62
|
+
if (!AuthManager.instance) {
|
|
63
|
+
throw new Error('AuthManager not initialized');
|
|
71
64
|
}
|
|
65
|
+
return AuthManager.instance;
|
|
66
|
+
}
|
|
72
67
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return this.refreshAccessToken();
|
|
77
|
-
}
|
|
78
|
-
return accessToken;
|
|
79
|
-
}
|
|
68
|
+
private tokenToPayload(token: string): UserTokenPayload {
|
|
69
|
+
return JSON.parse(atob(token.split('.')[1]));
|
|
70
|
+
}
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
private toBase64Url(base64String: string): string {
|
|
73
|
+
return base64String
|
|
74
|
+
.replace(/\+/g, '-')
|
|
75
|
+
.replace(/\//g, '_')
|
|
76
|
+
.replace(/=+$/, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private generatePKCEPair(): {
|
|
80
|
+
verifier: string,
|
|
81
|
+
challenge: string,
|
|
82
|
+
} {
|
|
83
|
+
const verifier =
|
|
84
|
+
localStorage.getItem('codeVerifier') ??
|
|
85
|
+
this.toBase64Url(randomBytes(32).toString('base64'));
|
|
86
|
+
const challenge =
|
|
87
|
+
localStorage.getItem('codeChallenge') ??
|
|
88
|
+
this.toBase64Url(
|
|
89
|
+
createHash('sha256').update(verifier).digest('base64'),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
localStorage.setItem('codeVerifier', verifier);
|
|
93
|
+
localStorage.setItem('codeChallenge', challenge);
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
return { verifier, challenge };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public async refreshAccessToken(): Promise<string> {
|
|
99
|
+
try {
|
|
100
|
+
const refreshToken = localStorage.getItem('refresh_token');
|
|
101
|
+
if (!refreshToken) {
|
|
102
|
+
throw new Error('No refresh token found');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await axios.post(
|
|
106
|
+
`${this.authServer}auth/refresh`,
|
|
107
|
+
{
|
|
108
|
+
refresh_token: refreshToken,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
this.saveTokens(response, true);
|
|
112
|
+
return response.data.access_token;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(`Refresh token error, logging out: ${error}`);
|
|
115
|
+
localStorage.removeItem('access_token');
|
|
116
|
+
localStorage.removeItem('refresh_token');
|
|
117
|
+
this.onStateChange({ type: AuthEventType.REFRESH_FAILED });
|
|
118
|
+
throw error;
|
|
88
119
|
}
|
|
120
|
+
}
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
122
|
+
public async checkAccessToken(): Promise<string> {
|
|
123
|
+
const accessToken = localStorage.getItem('access_token');
|
|
124
|
+
if (accessToken || this.isTokenExpired(accessToken)) {
|
|
125
|
+
return this.refreshAccessToken();
|
|
93
126
|
}
|
|
127
|
+
return accessToken;
|
|
128
|
+
}
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
private isTokenExpired(token: string): boolean {
|
|
131
|
+
const decoded = this.tokenToPayload(token);
|
|
132
|
+
return decoded.exp < Date.now() / 1000;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public async mustBeLoggedIn(): Promise<void> {
|
|
136
|
+
if (!(await this.isLoggedIn())) {
|
|
137
|
+
this.onStateChange({
|
|
138
|
+
type: AuthEventType.FAILED_MUST_LOGIN_CHECK,
|
|
139
|
+
});
|
|
102
140
|
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public getLoginWithGoogleUri(): string {
|
|
144
|
+
const { challenge } = this.generatePKCEPair();
|
|
145
|
+
return `${this.authServer}auth/login_with_google?realm_name=${this.realmName}&redirect_uri=${encodeURIComponent(this.redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
146
|
+
}
|
|
103
147
|
|
|
104
|
-
|
|
105
|
-
|
|
148
|
+
public async isLoggedIn(): Promise<boolean> {
|
|
149
|
+
try {
|
|
150
|
+
await this.checkAccessToken();
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return false;
|
|
106
154
|
}
|
|
155
|
+
}
|
|
107
156
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
157
|
+
public async getAccessToken(mustBeLoggedIn: boolean = false): Promise<string> {
|
|
158
|
+
try {
|
|
159
|
+
return await this.checkAccessToken();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (mustBeLoggedIn) {
|
|
162
|
+
this.onStateChange({
|
|
163
|
+
type: AuthEventType.FAILED_MUST_LOGIN_CHECK,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return '';
|
|
113
167
|
}
|
|
168
|
+
}
|
|
114
169
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
170
|
+
private saveTokens(response: AxiosResponse, byRefresh: boolean): void {
|
|
171
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
172
|
+
localStorage.setItem(
|
|
173
|
+
'refresh_token',
|
|
174
|
+
response.data.refresh_token,
|
|
175
|
+
);
|
|
176
|
+
this.onStateChange({
|
|
177
|
+
type: byRefresh ? AuthEventType.USER_UPDATED : AuthEventType.USER_LOGGED_IN,
|
|
178
|
+
user: this.tokenToPayload(response.data.access_token),
|
|
179
|
+
});
|
|
180
|
+
const user = this.tokenToPayload(response.data.access_token);
|
|
181
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
public async loginUsingPkce(code: string): Promise<void> {
|
|
185
|
+
try {
|
|
186
|
+
const codeVerifier = localStorage.getItem('codeVerifier');
|
|
187
|
+
if (!codeVerifier) {
|
|
188
|
+
throw new Error('Code verifier not found');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const response = await axios.post(
|
|
192
|
+
`${this.authServer}auth/pkce_exchange`,
|
|
193
|
+
{
|
|
194
|
+
realm_name: this.realmName,
|
|
195
|
+
code,
|
|
196
|
+
redirect_uri: this.redirectUri,
|
|
197
|
+
code_verifier: codeVerifier,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
this.saveTokens(response, false);
|
|
201
|
+
} finally {
|
|
202
|
+
localStorage.removeItem('codeVerifier');
|
|
203
|
+
localStorage.removeItem('codeChallenge');
|
|
133
204
|
}
|
|
205
|
+
}
|
|
134
206
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
207
|
+
public async logout(): Promise<void> {
|
|
208
|
+
try {
|
|
209
|
+
const accessToken = localStorage.getItem('access_token');
|
|
210
|
+
if (!accessToken) {
|
|
211
|
+
throw new Error('Access token not found');
|
|
212
|
+
}
|
|
213
|
+
await axios.post(
|
|
214
|
+
`${this.authServer}auth/logout`,
|
|
215
|
+
{},
|
|
216
|
+
{
|
|
217
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
} finally {
|
|
221
|
+
localStorage.removeItem('access_token');
|
|
222
|
+
localStorage.removeItem('refresh_token');
|
|
223
|
+
this.onStateChange({ type: AuthEventType.USER_LOGGED_OUT });
|
|
148
224
|
}
|
|
225
|
+
}
|
|
149
226
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
public static async validateToken(
|
|
228
|
+
authServer: string,
|
|
229
|
+
bearerToken: string,
|
|
230
|
+
): Promise<boolean> {
|
|
231
|
+
// @todo tests missing for this static validation
|
|
232
|
+
try {
|
|
233
|
+
const decodedToken = jwtDecode(bearerToken, {
|
|
234
|
+
complete: true,
|
|
235
|
+
})?.payload;
|
|
154
236
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
237
|
+
if (!decodedToken) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
158
240
|
|
|
159
|
-
|
|
160
|
-
|
|
241
|
+
const { data: publicKey } = await axios.get(
|
|
242
|
+
`${authServer}public/public_key`,
|
|
243
|
+
);
|
|
244
|
+
const { data: algo } = await axios.get(
|
|
245
|
+
`${authServer}public/algo`,
|
|
246
|
+
);
|
|
161
247
|
|
|
162
|
-
|
|
248
|
+
jwtVerify(bearerToken, publicKey, { algorithms: [algo] });
|
|
163
249
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
250
|
+
const { data: revokedIds } = await axios.get(
|
|
251
|
+
`${authServer}public/revoked_ids`,
|
|
252
|
+
);
|
|
253
|
+
// eslint-disable-next-line @typescript-eslint/dot-notation
|
|
254
|
+
return !revokedIds.includes(decodedToken['id']);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return false;
|
|
169
257
|
}
|
|
258
|
+
}
|
|
170
259
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
260
|
+
public static resetInstance(): void {
|
|
261
|
+
AuthManager.instance = null;
|
|
262
|
+
}
|
|
174
263
|
}
|
package/src/index.ts
CHANGED
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
export enum AuthEventType {
|
|
3
|
+
INITALIZED_IN = 'initialized-logged-in',
|
|
4
|
+
INITALIZED_OUT = 'initialized-logged-out',
|
|
5
|
+
USER_LOGGED_IN = 'user-logged-in',
|
|
6
|
+
USER_LOGGED_OUT = 'user-logged-out',
|
|
7
|
+
USER_UPDATED = 'user-updated',
|
|
8
|
+
FAILED_MUST_LOGIN_CHECK = 'failed-must-login',
|
|
9
|
+
REFRESH_FAILED = 'refresh-failed',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UserTokenPayload {
|
|
13
|
+
id: number;
|
|
14
|
+
iss: string;
|
|
15
|
+
sub: number;
|
|
16
|
+
first_name: string;
|
|
17
|
+
last_name: string;
|
|
18
|
+
email: string;
|
|
19
|
+
aud: string;
|
|
20
|
+
iat: number;
|
|
21
|
+
exp: number;
|
|
22
|
+
scopes: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthManagerEvent {
|
|
26
|
+
type: AuthEventType;
|
|
27
|
+
user?: UserTokenPayload;
|
|
28
|
+
}
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import MockAdapter from 'axios-mock-adapter';
|
|
3
3
|
import { AuthManager } from '../src/AuthManager';
|
|
4
|
+
import { AuthEventType } from '../src/types';
|
|
4
5
|
import { basename } from 'path';
|
|
5
6
|
|
|
6
7
|
const mock = new MockAdapter(axios);
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
const tokenThatWontExpire1 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZmlyc3RfbmFtZSI6IkpvaG4gRG9lIiwibGFzdF9uYW1lIjoiRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZXMiOiIvcm9vdC8qIiwiZXhwIjo5OTk5OTk5OTk5LCJpZCI6MiwiaXNzIjoxMjMsImF1ZCI6InRlc3RpbmcifQ.843X4Zq2WgNSu8fjRKx-kd_FbDqY_eVkgu2wZZbhhwE';
|
|
11
|
+
const tokenThatWontExpire2 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZmlyc3RfbmFtZSI6IkpvaG4gRG9lIiwibGFzdF9uYW1lIjoiRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZXMiOiIvcm9vdC8qIiwiZXhwIjo5OTk5OTk5OTk5LCJpZCI6MiwiaXNzIjoxMjMsImF1ZCI6InRlc3RpbmcifQ.843X4Zq2WgNSu8fjRKx-kd_FbDqY_eVkgu2wZZbhhwE';
|
|
12
|
+
const tokenThatExpired = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZmlyc3RfbmFtZSI6IkpvaG4gRG9lIiwibGFzdF9uYW1lIjoiRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJzY29wZXMiOiIvcm9vdC8qIiwiZXhwIjo1MDAsImlkIjoyLCJpc3MiOjEyMywiYXVkIjoidGVzdGluZyJ9.ungpbhHfCM5ZP5oiZ1RnMkJ-NKJI8s3_IPJptjyKHR4';
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
describe('AuthManager Tests', () => {
|
|
10
16
|
|
|
11
17
|
beforeEach(() => {
|
|
@@ -45,13 +51,13 @@ describe('AuthManager Tests', () => {
|
|
|
45
51
|
|
|
46
52
|
it('refreshes access token when expired', async () => {
|
|
47
53
|
mock.onPost('http://auth-server.com/auth/refresh').reply(200, {
|
|
48
|
-
access_token:
|
|
54
|
+
access_token: tokenThatWontExpire2,
|
|
49
55
|
refresh_token: 'newRefreshToken'
|
|
50
56
|
});
|
|
51
57
|
|
|
52
58
|
const loginCallback = jest.fn();
|
|
53
59
|
// check that we set localstorage correct
|
|
54
|
-
localStorage.setItem('access_token',
|
|
60
|
+
localStorage.setItem('access_token', tokenThatExpired);
|
|
55
61
|
localStorage.setItem('refresh_token', 'mockRefreshToken');
|
|
56
62
|
|
|
57
63
|
const refresh = localStorage.getItem('refresh_token');
|
|
@@ -60,8 +66,8 @@ describe('AuthManager Tests', () => {
|
|
|
60
66
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
61
67
|
const token = await manager.refreshAccessToken();
|
|
62
68
|
|
|
63
|
-
expect(token).toEqual(
|
|
64
|
-
expect(localStorage.setItem).toHaveBeenCalledWith('access_token',
|
|
69
|
+
expect(token).toEqual(tokenThatWontExpire2);
|
|
70
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', tokenThatWontExpire2);
|
|
65
71
|
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'newRefreshToken');
|
|
66
72
|
});
|
|
67
73
|
|
|
@@ -73,7 +79,9 @@ describe('AuthManager Tests', () => {
|
|
|
73
79
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
74
80
|
|
|
75
81
|
await expect(manager.refreshAccessToken()).rejects.toThrow('No refresh token found');
|
|
76
|
-
await expect(loginCallback).
|
|
82
|
+
await expect(loginCallback).toHaveBeenCalledWith({
|
|
83
|
+
type: AuthEventType.REFRESH_FAILED,
|
|
84
|
+
});
|
|
77
85
|
});
|
|
78
86
|
|
|
79
87
|
it('logs in using PKCE and updates local storage', async () => {
|
|
@@ -104,11 +112,11 @@ describe('AuthManager Tests', () => {
|
|
|
104
112
|
});
|
|
105
113
|
|
|
106
114
|
it('logs out and clears local storage', async () => {
|
|
107
|
-
localStorage.setItem('access_token', 'validAccessToken');
|
|
108
115
|
mock.onPost('http://auth-server.com/auth/logout').reply(200);
|
|
109
|
-
|
|
116
|
+
|
|
110
117
|
const loginCallback = jest.fn();
|
|
111
118
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
119
|
+
localStorage.setItem('access_token', tokenThatWontExpire1);
|
|
112
120
|
await manager.logout();
|
|
113
121
|
|
|
114
122
|
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|