supaapps-auth 1.4.0 → 2.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +3 -0
- package/.github/workflows/ci.yml +1 -0
- package/.prettierrc +7 -0
- package/dist/AuthManager.d.ts +7 -4
- package/dist/AuthManager.js +67 -22
- 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 +18 -4
- package/src/AuthManager.ts +228 -131
- package/src/index.ts +1 -0
- package/src/types.ts +28 -0
- package/tests/AuthManager.test.ts +30 -9
package/.eslintrc
ADDED
package/.github/workflows/ci.yml
CHANGED
package/.prettierrc
ADDED
package/dist/AuthManager.d.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
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>;
|
|
21
|
+
private saveTokens;
|
|
19
22
|
loginUsingPkce(code: string): Promise<void>;
|
|
20
23
|
logout(): Promise<void>;
|
|
21
24
|
static validateToken(authServer: string, bearerToken: string): Promise<boolean>;
|
package/dist/AuthManager.js
CHANGED
|
@@ -13,18 +13,30 @@ 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);
|
|
27
28
|
}
|
|
29
|
+
AuthManager.instance
|
|
30
|
+
.checkAccessToken()
|
|
31
|
+
.then((token) => {
|
|
32
|
+
onStateChange({
|
|
33
|
+
type: types_1.AuthEventType.INITALIZED_IN,
|
|
34
|
+
user: AuthManager.instance.tokenToPayload(token),
|
|
35
|
+
});
|
|
36
|
+
})
|
|
37
|
+
.catch(() => {
|
|
38
|
+
onStateChange({ type: types_1.AuthEventType.INITALIZED_OUT });
|
|
39
|
+
});
|
|
28
40
|
return AuthManager.instance;
|
|
29
41
|
}
|
|
30
42
|
static getInstance() {
|
|
@@ -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,37 +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);
|
|
75
|
+
this.saveTokens(response, true);
|
|
59
76
|
return response.data.access_token;
|
|
60
77
|
}
|
|
61
78
|
catch (error) {
|
|
62
79
|
console.error(`Refresh token error, logging out: ${error}`);
|
|
63
80
|
localStorage.removeItem('access_token');
|
|
64
81
|
localStorage.removeItem('refresh_token');
|
|
65
|
-
this.
|
|
82
|
+
this.onStateChange({ type: types_1.AuthEventType.REFRESH_FAILED });
|
|
66
83
|
throw error;
|
|
67
84
|
}
|
|
68
85
|
});
|
|
69
86
|
}
|
|
70
87
|
checkAccessToken() {
|
|
71
88
|
return __awaiter(this, void 0, void 0, function* () {
|
|
72
|
-
|
|
73
|
-
if (
|
|
89
|
+
const accessToken = localStorage.getItem('access_token');
|
|
90
|
+
if (accessToken || this.isTokenExpired(accessToken)) {
|
|
74
91
|
return this.refreshAccessToken();
|
|
75
92
|
}
|
|
76
93
|
return accessToken;
|
|
77
94
|
});
|
|
78
95
|
}
|
|
79
96
|
isTokenExpired(token) {
|
|
80
|
-
const decoded =
|
|
97
|
+
const decoded = this.tokenToPayload(token);
|
|
81
98
|
return decoded.exp < Date.now() / 1000;
|
|
82
99
|
}
|
|
83
100
|
mustBeLoggedIn() {
|
|
84
101
|
return __awaiter(this, void 0, void 0, function* () {
|
|
85
|
-
|
|
102
|
+
if (!(yield this.isLoggedIn())) {
|
|
103
|
+
this.onStateChange({
|
|
104
|
+
type: types_1.AuthEventType.FAILED_MUST_LOGIN_CHECK,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
86
107
|
});
|
|
87
108
|
}
|
|
88
109
|
getLoginWithGoogleUri() {
|
|
@@ -101,9 +122,29 @@ class AuthManager {
|
|
|
101
122
|
});
|
|
102
123
|
}
|
|
103
124
|
getAccessToken() {
|
|
104
|
-
return __awaiter(this,
|
|
105
|
-
|
|
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
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
saveTokens(response, byRefresh) {
|
|
140
|
+
localStorage.setItem('access_token', response.data.access_token);
|
|
141
|
+
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
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),
|
|
106
145
|
});
|
|
146
|
+
const user = this.tokenToPayload(response.data.access_token);
|
|
147
|
+
localStorage.setItem('user', JSON.stringify(user));
|
|
107
148
|
}
|
|
108
149
|
loginUsingPkce(code) {
|
|
109
150
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -118,8 +159,7 @@ class AuthManager {
|
|
|
118
159
|
redirect_uri: this.redirectUri,
|
|
119
160
|
code_verifier: codeVerifier,
|
|
120
161
|
});
|
|
121
|
-
|
|
122
|
-
localStorage.setItem('refresh_token', response.data.refresh_token);
|
|
162
|
+
this.saveTokens(response, false);
|
|
123
163
|
}
|
|
124
164
|
finally {
|
|
125
165
|
localStorage.removeItem('codeVerifier');
|
|
@@ -135,27 +175,32 @@ class AuthManager {
|
|
|
135
175
|
throw new Error('Access token not found');
|
|
136
176
|
}
|
|
137
177
|
yield axios_1.default.post(`${this.authServer}auth/logout`, {}, {
|
|
138
|
-
headers: { Authorization: `Bearer ${accessToken}` }
|
|
178
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
139
179
|
});
|
|
140
180
|
}
|
|
141
181
|
finally {
|
|
142
182
|
localStorage.removeItem('access_token');
|
|
143
183
|
localStorage.removeItem('refresh_token');
|
|
184
|
+
this.onStateChange({ type: types_1.AuthEventType.USER_LOGGED_OUT });
|
|
144
185
|
}
|
|
145
186
|
});
|
|
146
187
|
}
|
|
147
188
|
static validateToken(authServer, bearerToken) {
|
|
148
|
-
var _a;
|
|
149
189
|
return __awaiter(this, void 0, void 0, function* () {
|
|
190
|
+
var _a;
|
|
191
|
+
// @todo tests missing for this static validation
|
|
150
192
|
try {
|
|
151
|
-
const decodedToken = (_a = jsonwebtoken_1.
|
|
152
|
-
|
|
193
|
+
const decodedToken = (_a = (0, jsonwebtoken_1.decode)(bearerToken, {
|
|
194
|
+
complete: true,
|
|
195
|
+
})) === null || _a === void 0 ? void 0 : _a.payload;
|
|
196
|
+
if (!decodedToken) {
|
|
153
197
|
return false;
|
|
154
198
|
}
|
|
155
199
|
const { data: publicKey } = yield axios_1.default.get(`${authServer}public/public_key`);
|
|
156
200
|
const { data: algo } = yield axios_1.default.get(`${authServer}public/algo`);
|
|
157
|
-
jsonwebtoken_1.
|
|
201
|
+
(0, jsonwebtoken_1.verify)(bearerToken, publicKey, { algorithms: [algo] });
|
|
158
202
|
const { data: revokedIds } = yield axios_1.default.get(`${authServer}public/revoked_ids`);
|
|
203
|
+
// eslint-disable-next-line @typescript-eslint/dot-notation
|
|
159
204
|
return !revokedIds.includes(decodedToken['id']);
|
|
160
205
|
}
|
|
161
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.1",
|
|
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,13 +17,26 @@
|
|
|
16
17
|
"jsonwebtoken": "^9.0.2"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
20
|
+
"@next/eslint-plugin-next": "^13.5.6",
|
|
21
|
+
"@types/axios": "^0.14.0",
|
|
19
22
|
"@types/jest": "^29.5.12",
|
|
20
|
-
"@types/
|
|
23
|
+
"@types/jsonwebtoken": "^9.0.6",
|
|
24
|
+
"@types/node": "^20.12.12",
|
|
25
|
+
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
26
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
21
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",
|
|
22
35
|
"jest": "^29.7.0",
|
|
23
36
|
"jest-localstorage-mock": "^2.4.26",
|
|
24
37
|
"jest-mock-axios": "^4.7.3",
|
|
25
38
|
"ts-jest": "^29.1.2",
|
|
26
|
-
"typescript": "^5.3.3"
|
|
39
|
+
"typescript": "^5.3.3",
|
|
40
|
+
"undefined": "^0.1.0"
|
|
27
41
|
}
|
|
28
42
|
}
|
package/src/AuthManager.ts
CHANGED
|
@@ -1,166 +1,263 @@
|
|
|
1
|
-
import axios from 'axios';
|
|
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
46
|
}
|
|
47
|
+
AuthManager.instance
|
|
48
|
+
.checkAccessToken()
|
|
49
|
+
.then((token) => {
|
|
50
|
+
onStateChange({
|
|
51
|
+
type: AuthEventType.INITALIZED_IN,
|
|
52
|
+
user: AuthManager.instance.tokenToPayload(token),
|
|
53
|
+
});
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
onStateChange({ type: AuthEventType.INITALIZED_OUT });
|
|
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
|
-
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
|
-
}
|
|
61
|
+
public static getInstance(): AuthManager {
|
|
62
|
+
if (!AuthManager.instance) {
|
|
63
|
+
throw new Error('AuthManager not initialized');
|
|
69
64
|
}
|
|
65
|
+
return AuthManager.instance;
|
|
66
|
+
}
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
private tokenToPayload(token: string): UserTokenPayload {
|
|
69
|
+
return JSON.parse(atob(token.split('.')[1]));
|
|
70
|
+
}
|
|
71
|
+
|
|
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);
|
|
78
94
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
119
|
}
|
|
120
|
+
}
|
|
83
121
|
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
public async checkAccessToken(): Promise<string> {
|
|
123
|
+
const accessToken = localStorage.getItem('access_token');
|
|
124
|
+
if (accessToken || this.isTokenExpired(accessToken)) {
|
|
125
|
+
return this.refreshAccessToken();
|
|
86
126
|
}
|
|
127
|
+
return accessToken;
|
|
128
|
+
}
|
|
87
129
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
});
|
|
91
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
|
+
}
|
|
92
147
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
148
|
+
public async isLoggedIn(): Promise<boolean> {
|
|
149
|
+
try {
|
|
150
|
+
await this.checkAccessToken();
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return false;
|
|
100
154
|
}
|
|
155
|
+
}
|
|
101
156
|
|
|
102
|
-
|
|
103
|
-
|
|
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 '';
|
|
104
167
|
}
|
|
168
|
+
}
|
|
105
169
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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');
|
|
126
204
|
}
|
|
205
|
+
}
|
|
127
206
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 });
|
|
141
224
|
}
|
|
225
|
+
}
|
|
142
226
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
146
236
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
237
|
+
if (!decodedToken) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
150
240
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
);
|
|
153
247
|
|
|
154
|
-
|
|
248
|
+
jwtVerify(bearerToken, publicKey, { algorithms: [algo] });
|
|
155
249
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
257
|
}
|
|
258
|
+
}
|
|
162
259
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
260
|
+
public static resetInstance(): void {
|
|
261
|
+
AuthManager.instance = null;
|
|
262
|
+
}
|
|
166
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,10 +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';
|
|
5
|
+
import { basename } from 'path';
|
|
4
6
|
|
|
5
7
|
const mock = new MockAdapter(axios);
|
|
6
8
|
|
|
7
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
|
+
|
|
8
15
|
describe('AuthManager Tests', () => {
|
|
9
16
|
|
|
10
17
|
beforeEach(() => {
|
|
@@ -44,13 +51,13 @@ describe('AuthManager Tests', () => {
|
|
|
44
51
|
|
|
45
52
|
it('refreshes access token when expired', async () => {
|
|
46
53
|
mock.onPost('http://auth-server.com/auth/refresh').reply(200, {
|
|
47
|
-
access_token:
|
|
54
|
+
access_token: tokenThatWontExpire2,
|
|
48
55
|
refresh_token: 'newRefreshToken'
|
|
49
56
|
});
|
|
50
57
|
|
|
51
58
|
const loginCallback = jest.fn();
|
|
52
59
|
// check that we set localstorage correct
|
|
53
|
-
localStorage.setItem('access_token',
|
|
60
|
+
localStorage.setItem('access_token', tokenThatExpired);
|
|
54
61
|
localStorage.setItem('refresh_token', 'mockRefreshToken');
|
|
55
62
|
|
|
56
63
|
const refresh = localStorage.getItem('refresh_token');
|
|
@@ -59,8 +66,8 @@ describe('AuthManager Tests', () => {
|
|
|
59
66
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
60
67
|
const token = await manager.refreshAccessToken();
|
|
61
68
|
|
|
62
|
-
expect(token).toEqual(
|
|
63
|
-
expect(localStorage.setItem).toHaveBeenCalledWith('access_token',
|
|
69
|
+
expect(token).toEqual(tokenThatWontExpire2);
|
|
70
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', tokenThatWontExpire2);
|
|
64
71
|
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'newRefreshToken');
|
|
65
72
|
});
|
|
66
73
|
|
|
@@ -72,13 +79,25 @@ describe('AuthManager Tests', () => {
|
|
|
72
79
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
73
80
|
|
|
74
81
|
await expect(manager.refreshAccessToken()).rejects.toThrow('No refresh token found');
|
|
75
|
-
await expect(loginCallback).
|
|
82
|
+
await expect(loginCallback).toHaveBeenCalledWith({
|
|
83
|
+
type: AuthEventType.REFRESH_FAILED,
|
|
84
|
+
});
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
it('logs in using PKCE and updates local storage', async () => {
|
|
79
88
|
localStorage.setItem('codeVerifier', 'mockCodeVerifier');
|
|
89
|
+
/*
|
|
90
|
+
{
|
|
91
|
+
"sub": "1234567890",
|
|
92
|
+
"name": "John Doe",
|
|
93
|
+
"iat": 1516239022
|
|
94
|
+
}
|
|
95
|
+
*/
|
|
96
|
+
const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
|
97
|
+
|
|
98
|
+
|
|
80
99
|
mock.onPost('http://auth-server.com/auth/pkce_exchange').reply(200, {
|
|
81
|
-
access_token:
|
|
100
|
+
access_token: accessToken,
|
|
82
101
|
refresh_token: 'validRefreshToken'
|
|
83
102
|
});
|
|
84
103
|
|
|
@@ -86,16 +105,18 @@ describe('AuthManager Tests', () => {
|
|
|
86
105
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
87
106
|
await manager.loginUsingPkce('mockCode');
|
|
88
107
|
|
|
89
|
-
expect(localStorage.setItem).toHaveBeenCalledWith('access_token',
|
|
108
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', accessToken);
|
|
90
109
|
expect(localStorage.setItem).toHaveBeenCalledWith('refresh_token', 'validRefreshToken');
|
|
110
|
+
const userSub = JSON.parse(localStorage.getItem('user') ?? '').sub;
|
|
111
|
+
expect(userSub).toEqual('1234567890');
|
|
91
112
|
});
|
|
92
113
|
|
|
93
114
|
it('logs out and clears local storage', async () => {
|
|
94
|
-
localStorage.setItem('access_token', 'validAccessToken');
|
|
95
115
|
mock.onPost('http://auth-server.com/auth/logout').reply(200);
|
|
96
|
-
|
|
116
|
+
|
|
97
117
|
const loginCallback = jest.fn();
|
|
98
118
|
const manager = AuthManager.initialize('http://auth-server.com/', 'example-realm', 'http://myapp.com/callback', loginCallback);
|
|
119
|
+
localStorage.setItem('access_token', tokenThatWontExpire1);
|
|
99
120
|
await manager.logout();
|
|
100
121
|
|
|
101
122
|
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token');
|