@gh-platform/auth-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitlab-ci.yml +22 -0
- package/README.md +180 -0
- package/dist/auth-sdk.es.js +239 -0
- package/dist/auth-sdk.min.js +1 -0
- package/dist/auth-sdk.umd.js +1 -0
- package/dist/index.d.ts +93 -0
- package/package.json +23 -0
- package/src/client.js +140 -0
- package/src/index.d.ts +93 -0
- package/src/index.js +7 -0
- package/src/middleware.js +110 -0
- package/src/storage.js +43 -0
- package/vite.config.js +19 -0
package/.gitlab-ci.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
stages:
|
|
2
|
+
- build
|
|
3
|
+
- publish
|
|
4
|
+
|
|
5
|
+
build_package:
|
|
6
|
+
stage: build
|
|
7
|
+
image: node:20
|
|
8
|
+
script:
|
|
9
|
+
- npm ci --include=dev
|
|
10
|
+
- npm run build
|
|
11
|
+
artifacts:
|
|
12
|
+
paths:
|
|
13
|
+
- dist/
|
|
14
|
+
|
|
15
|
+
publish_to_registry:
|
|
16
|
+
stage: publish
|
|
17
|
+
image: node:20
|
|
18
|
+
script:
|
|
19
|
+
- echo "//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${NPM_TOKEN}" > .npmrc
|
|
20
|
+
- npm publish
|
|
21
|
+
only:
|
|
22
|
+
- tags
|
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# 🛡️ GH Platform – JavaScript Auth SDK (Multi‑Tenant Version)
|
|
2
|
+
|
|
3
|
+
SDK JavaScript hỗ trợ đăng nhập (**login**), làm mới token (**refresh**), kiểm tra token (**introspect**),
|
|
4
|
+
và tự động xác thực request HTTP trong kiến trúc **multi‑tenant** của GH Platform.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 🚀 Tính năng chính
|
|
9
|
+
|
|
10
|
+
- ✅ Hỗ trợ **login**, **refresh**, **introspect**
|
|
11
|
+
- ✅ **Tenant‑aware client**: mọi API tự động gắn `{tenant}` vào URL
|
|
12
|
+
- ✅ Middleware **AuthFetch** tự động gắn Bearer token + auto refresh
|
|
13
|
+
- ✅ Hoạt động cả **browser** & **Node.js**
|
|
14
|
+
- ✅ Hỗ trợ nhúng trực tiếp (`auth-sdk.min.js`) hoặc cài qua **npm**
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🧱 Kiến trúc Multi‑Tenant
|
|
19
|
+
|
|
20
|
+
Mọi API của GH Platform Authenticate đều sử dụng dạng:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
/api/v1/{tenant}/auth/login
|
|
24
|
+
/api/v1/{tenant}/auth/refresh
|
|
25
|
+
/api/v1/{tenant}/auth/introspect
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
JavaScript SDK tự động truyền tenant trong mọi request.
|
|
29
|
+
|
|
30
|
+
### ✳️ Khởi tạo client theo tenant
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const tenant = "demo"; // hoặc trích xuất từ email: alice@example.com → example.com
|
|
34
|
+
|
|
35
|
+
const client = new AuthClient({
|
|
36
|
+
baseUrl: "https://auth.example.com",
|
|
37
|
+
tenant
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const storage = new TokenStorage("auth", tenant);
|
|
41
|
+
const fetcher = new AuthFetch(client, storage);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 📌 TokenStorage cũng tách token theo tenant
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// Lưu token theo từng tenant
|
|
48
|
+
storage.accessToken = res.access_token;
|
|
49
|
+
storage.refreshToken = res.refresh_token;
|
|
50
|
+
|
|
51
|
+
// Key lưu trong localStorage sẽ giống:
|
|
52
|
+
// auth.demo.access_token
|
|
53
|
+
// auth.demo.refresh_token
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 📦 Cài đặt
|
|
59
|
+
|
|
60
|
+
### 🔹 Cách 1 – Cài qua NPM
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install @gh-platform/auth-sdk
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Import:
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { AuthClient, AuthFetch, TokenStorage } from "@gh-platform/auth-sdk";
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### 🔹 Cách 2 – Dùng trực tiếp trên Web (HTML thuần)
|
|
75
|
+
|
|
76
|
+
```html
|
|
77
|
+
<script src="https://cdn.yourdomain.com/auth-sdk.min.js"></script>
|
|
78
|
+
<script>
|
|
79
|
+
const { AuthClient, AuthFetch, TokenStorage } = window.AuthSDK;
|
|
80
|
+
</script>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 🔐 Ví dụ Multi‑Tenant Login
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const tenant = "example";
|
|
89
|
+
const client = new AuthClient({
|
|
90
|
+
baseUrl: "https://auth.example.com",
|
|
91
|
+
tenant
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function login() {
|
|
95
|
+
const res = await client.login(
|
|
96
|
+
"alice@example.com", // identifier
|
|
97
|
+
"User@123", // password
|
|
98
|
+
"gh-platform-admin" // client_id
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
storage.accessToken = res.access_token;
|
|
102
|
+
storage.refreshToken = res.refresh_token;
|
|
103
|
+
|
|
104
|
+
console.log("Login success!", res);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 🌐 Fetch API với AuthFetch (auto refresh + retry)
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
const fetcher = new AuthFetch(client, storage);
|
|
114
|
+
|
|
115
|
+
const resp = await fetcher.fetch(
|
|
116
|
+
client.baseUrl + "/api/v1/" + tenant + "/auth/me"
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const user = await resp.json();
|
|
120
|
+
console.log("User:", user);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tự động:
|
|
124
|
+
|
|
125
|
+
- Gắn `Authorization: Bearer <access_token>`
|
|
126
|
+
- Nếu token hết hạn → tự refresh → retry request
|
|
127
|
+
- Lưu token đúng tenant
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🔧 Build hướng dẫn
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm install vite terser -D
|
|
135
|
+
npm run build
|
|
136
|
+
npx terser dist/auth-sdk.umd.js -o dist/auth-sdk.min.js --compress --mangle
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 🌐 Demo HTML
|
|
142
|
+
|
|
143
|
+
```html
|
|
144
|
+
<!DOCTYPE html>
|
|
145
|
+
<html>
|
|
146
|
+
<head><title>Auth SDK Demo</title></head>
|
|
147
|
+
<body>
|
|
148
|
+
<button id="login">Login</button>
|
|
149
|
+
<button id="getUser">Get Profile</button>
|
|
150
|
+
|
|
151
|
+
<script src="./dist/auth-sdk.min.js"></script>
|
|
152
|
+
<script>
|
|
153
|
+
const tenant = "demo";
|
|
154
|
+
|
|
155
|
+
const { AuthClient, TokenStorage, AuthFetch } = window.AuthSDK;
|
|
156
|
+
const client = new AuthClient({ baseUrl: "https://auth.example.com", tenant });
|
|
157
|
+
const storage = new TokenStorage("auth", tenant);
|
|
158
|
+
const fetcher = new AuthFetch(client, storage);
|
|
159
|
+
|
|
160
|
+
document.getElementById("login").onclick = async () => {
|
|
161
|
+
const res = await client.login("alice@example.com", "User@123", "gh-platform-admin");
|
|
162
|
+
storage.accessToken = res.access_token;
|
|
163
|
+
storage.refreshToken = res.refresh_token;
|
|
164
|
+
alert("Login success!");
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
document.getElementById("getUser").onclick = async () => {
|
|
168
|
+
const r = await fetcher.fetch(client.baseUrl + "/api/v1/" + tenant + "/auth/me");
|
|
169
|
+
console.log("User:", await r.json());
|
|
170
|
+
};
|
|
171
|
+
</script>
|
|
172
|
+
</body>
|
|
173
|
+
</html>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 🧾 License
|
|
179
|
+
|
|
180
|
+
Bản quyền © 2025 **GH Platform** – Phát hành theo giấy phép **MIT**
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
class AuthClient {
|
|
2
|
+
/**
|
|
3
|
+
* options:
|
|
4
|
+
* - baseUrl (required) e.g. "https://auth.example.com"
|
|
5
|
+
* - tenant (optional) e.g. "demo" -> will target /api/v1/{tenant}/auth/*
|
|
6
|
+
* - loginPath / refreshPath / introspectPath (optional overrides)
|
|
7
|
+
* - headers (optional)
|
|
8
|
+
*/
|
|
9
|
+
constructor({
|
|
10
|
+
baseUrl,
|
|
11
|
+
tenant = null,
|
|
12
|
+
loginPath = null,
|
|
13
|
+
refreshPath = null,
|
|
14
|
+
introspectPath = null,
|
|
15
|
+
headers = {},
|
|
16
|
+
storage = null
|
|
17
|
+
// <-- thêm storage vào constructor
|
|
18
|
+
}) {
|
|
19
|
+
if (!baseUrl) throw new Error("baseUrl is required");
|
|
20
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
21
|
+
const prefix = tenant ? `/api/v1/${tenant}/auth` : `/api/v1/auth`;
|
|
22
|
+
this.loginUrl = this.baseUrl + (loginPath || `${prefix}/login`);
|
|
23
|
+
this.refreshUrl = this.baseUrl + (refreshPath || `${prefix}/refresh`);
|
|
24
|
+
this.introspectUrl = this.baseUrl + (introspectPath || `${prefix}/me`);
|
|
25
|
+
this.tenant = tenant;
|
|
26
|
+
this.headers = { "Content-Type": "application/json", ...headers };
|
|
27
|
+
this.storage = storage;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Login payload uses identifier + password (+ optional totp)
|
|
31
|
+
* extra is optional object for backward-compat or extra fields
|
|
32
|
+
*/
|
|
33
|
+
async login(identifier, password, totp = null, extra = {}) {
|
|
34
|
+
const body = {
|
|
35
|
+
identifier,
|
|
36
|
+
password,
|
|
37
|
+
...extra
|
|
38
|
+
};
|
|
39
|
+
if (totp) body.totp = totp;
|
|
40
|
+
const res = await fetch(this.loginUrl, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: this.headers,
|
|
43
|
+
body: JSON.stringify(body)
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const text = await res.text().catch(() => res.statusText);
|
|
47
|
+
throw new Error(`Login failed: ${res.status} ${text}`);
|
|
48
|
+
}
|
|
49
|
+
let json;
|
|
50
|
+
try {
|
|
51
|
+
json = await res.json();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error("❌ JSON parse error:", e);
|
|
54
|
+
throw new Error("Invalid JSON response from server");
|
|
55
|
+
}
|
|
56
|
+
const data = json.data || json;
|
|
57
|
+
if (this.storage) {
|
|
58
|
+
if (data.access_token) this.storage.accessToken = data.access_token;
|
|
59
|
+
if (data.refresh_token) this.storage.refreshToken = data.refresh_token;
|
|
60
|
+
}
|
|
61
|
+
return json;
|
|
62
|
+
}
|
|
63
|
+
async refresh(refreshToken) {
|
|
64
|
+
const res = await fetch(this.refreshUrl, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: this.headers,
|
|
67
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
const text = await res.text().catch(() => res.statusText);
|
|
71
|
+
throw new Error(`Refresh failed: ${res.status} ${text}`);
|
|
72
|
+
}
|
|
73
|
+
let json;
|
|
74
|
+
try {
|
|
75
|
+
json = await res.json();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
console.error("❌ JSON parse error:", e);
|
|
78
|
+
throw new Error("Invalid JSON response from server");
|
|
79
|
+
}
|
|
80
|
+
const data = json.data || json;
|
|
81
|
+
if (this.storage) {
|
|
82
|
+
if (data.access_token) this.storage.accessToken = data.access_token;
|
|
83
|
+
if (data.refresh_token) this.storage.refreshToken = data.refresh_token;
|
|
84
|
+
}
|
|
85
|
+
return json;
|
|
86
|
+
}
|
|
87
|
+
async introspect(token = null) {
|
|
88
|
+
let finalToken = token;
|
|
89
|
+
if (this.storage && !finalToken) {
|
|
90
|
+
finalToken = this.storage.accessToken;
|
|
91
|
+
}
|
|
92
|
+
if (!finalToken) {
|
|
93
|
+
throw new Error("No access token available for introspection");
|
|
94
|
+
}
|
|
95
|
+
const headers = new Headers(this.headers);
|
|
96
|
+
headers.set("Authorization", `Bearer ${finalToken}`);
|
|
97
|
+
const res = await fetch(this.introspectUrl, {
|
|
98
|
+
method: "GET",
|
|
99
|
+
headers
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const text = await res.text().catch(() => res.statusText);
|
|
103
|
+
throw new Error(`Introspect failed: ${res.status} ${text}`);
|
|
104
|
+
}
|
|
105
|
+
let json;
|
|
106
|
+
try {
|
|
107
|
+
json = await res.json();
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error("❌ JSON parse error:", e);
|
|
110
|
+
throw new Error("Invalid JSON response from server");
|
|
111
|
+
}
|
|
112
|
+
return json;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
class TokenStorage {
|
|
116
|
+
/**
|
|
117
|
+
* prefix: base prefix (default "auth")
|
|
118
|
+
* tenant: optional tenant string -> final key = `${prefix}:${tenant}_access_token`
|
|
119
|
+
*/
|
|
120
|
+
constructor(prefix = "auth", tenant = null) {
|
|
121
|
+
this.prefix = prefix;
|
|
122
|
+
this.tenant = tenant;
|
|
123
|
+
}
|
|
124
|
+
_key(name) {
|
|
125
|
+
return this.tenant ? `${this.prefix}:${this.tenant}_${name}` : `${this.prefix}_${name}`;
|
|
126
|
+
}
|
|
127
|
+
get accessToken() {
|
|
128
|
+
return localStorage.getItem(this._key("access_token"));
|
|
129
|
+
}
|
|
130
|
+
set accessToken(val) {
|
|
131
|
+
if (val === null || val === void 0) {
|
|
132
|
+
localStorage.removeItem(this._key("access_token"));
|
|
133
|
+
} else {
|
|
134
|
+
localStorage.setItem(this._key("access_token"), val);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
get refreshToken() {
|
|
138
|
+
return localStorage.getItem(this._key("refresh_token"));
|
|
139
|
+
}
|
|
140
|
+
set refreshToken(val) {
|
|
141
|
+
if (val === null || val === void 0) {
|
|
142
|
+
localStorage.removeItem(this._key("refresh_token"));
|
|
143
|
+
} else {
|
|
144
|
+
localStorage.setItem(this._key("refresh_token"), val);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
clear() {
|
|
148
|
+
localStorage.removeItem(this._key("access_token"));
|
|
149
|
+
localStorage.removeItem(this._key("refresh_token"));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
class AuthFetch {
|
|
153
|
+
constructor(authClient, storage = null) {
|
|
154
|
+
this.client = authClient;
|
|
155
|
+
this.storage = storage || new TokenStorage("auth", authClient.tenant || null);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* fetchWithProgress:
|
|
159
|
+
* - input: URL
|
|
160
|
+
* - options: fetch options
|
|
161
|
+
* - onProgress: callback(percent, loaded, total)
|
|
162
|
+
*/
|
|
163
|
+
async fetch(input, options = {}, onProgress = null) {
|
|
164
|
+
const token = this.storage.accessToken;
|
|
165
|
+
const headers = new Headers(options.headers || {});
|
|
166
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
167
|
+
if (options.method && options.method !== "GET" && options.body) {
|
|
168
|
+
return await this._xhrRequest(input, options, headers, onProgress);
|
|
169
|
+
}
|
|
170
|
+
return await this._fetchWithDownloadProgress(input, options, headers, onProgress);
|
|
171
|
+
}
|
|
172
|
+
// ------------------ DOWNLOAD PROGRESS ------------------
|
|
173
|
+
async _fetchWithDownloadProgress(input, options, headers, onProgress) {
|
|
174
|
+
let response = await fetch(input, { ...options, headers });
|
|
175
|
+
if (response.status === 401 && this.storage.refreshToken) {
|
|
176
|
+
try {
|
|
177
|
+
const newTokens = await this.client.refresh(this.storage.refreshToken);
|
|
178
|
+
if (newTokens.access_token) this.storage.accessToken = newTokens.access_token;
|
|
179
|
+
if (newTokens.refresh_token) this.storage.refreshToken = newTokens.refresh_token;
|
|
180
|
+
headers.set("Authorization", `Bearer ${this.storage.accessToken}`);
|
|
181
|
+
response = await fetch(input, { ...options, headers });
|
|
182
|
+
} catch {
|
|
183
|
+
this.storage.clear();
|
|
184
|
+
throw new Error("Unauthorized, please login again");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!onProgress || !response.body) return response;
|
|
188
|
+
const reader = response.body.getReader();
|
|
189
|
+
const contentLength = +response.headers.get("Content-Length") || 0;
|
|
190
|
+
let loaded = 0;
|
|
191
|
+
const chunks = [];
|
|
192
|
+
while (true) {
|
|
193
|
+
const { done, value } = await reader.read();
|
|
194
|
+
if (done) break;
|
|
195
|
+
chunks.push(value);
|
|
196
|
+
loaded += value.length;
|
|
197
|
+
if (contentLength) {
|
|
198
|
+
const percent = Math.round(loaded / contentLength * 100);
|
|
199
|
+
onProgress(percent, loaded, contentLength);
|
|
200
|
+
} else {
|
|
201
|
+
onProgress(null, loaded, null);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const blob = new Blob(chunks);
|
|
205
|
+
return new Response(blob, response);
|
|
206
|
+
}
|
|
207
|
+
// ------------------ UPLOAD PROGRESS ------------------
|
|
208
|
+
_xhrRequest(url, options, headers, onProgress) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
const xhr = new XMLHttpRequest();
|
|
211
|
+
xhr.open(options.method || "POST", url, true);
|
|
212
|
+
for (const [k, v] of headers.entries()) {
|
|
213
|
+
xhr.setRequestHeader(k, v);
|
|
214
|
+
}
|
|
215
|
+
if (xhr.upload && onProgress) {
|
|
216
|
+
xhr.upload.onprogress = (e) => {
|
|
217
|
+
if (e.lengthComputable) {
|
|
218
|
+
const percent = Math.round(e.loaded / e.total * 100);
|
|
219
|
+
onProgress(percent, e.loaded, e.total);
|
|
220
|
+
} else {
|
|
221
|
+
onProgress(null, e.loaded, null);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
xhr.onload = () => {
|
|
226
|
+
resolve(new Response(xhr.response, { status: xhr.status }));
|
|
227
|
+
};
|
|
228
|
+
xhr.onerror = () => reject(new Error("Network error"));
|
|
229
|
+
xhr.send(options.body);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const index = { AuthClient, AuthFetch, TokenStorage };
|
|
234
|
+
export {
|
|
235
|
+
AuthClient,
|
|
236
|
+
AuthFetch,
|
|
237
|
+
TokenStorage,
|
|
238
|
+
index as default
|
|
239
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).AuthSDK={})}(this,function(e){"use strict";class t{constructor({baseUrl:e,tenant:t=null,loginPath:s=null,refreshPath:r=null,introspectPath:o=null,headers:a={},storage:n=null}){if(!e)throw new Error("baseUrl is required");this.baseUrl=e.replace(/\/$/,"");const h=t?`/api/v1/${t}/auth`:"/api/v1/auth";this.loginUrl=this.baseUrl+(s||`${h}/login`),this.refreshUrl=this.baseUrl+(r||`${h}/refresh`),this.introspectUrl=this.baseUrl+(o||`${h}/me`),this.tenant=t,this.headers={"Content-Type":"application/json",...a},this.storage=n}async login(e,t,s=null,r={}){const o={identifier:e,password:t,...r};s&&(o.totp=s);const a=await fetch(this.loginUrl,{method:"POST",headers:this.headers,body:JSON.stringify(o)});if(!a.ok){const e=await a.text().catch(()=>a.statusText);throw new Error(`Login failed: ${a.status} ${e}`)}let n;try{n=await a.json()}catch(e){throw console.error("❌ JSON parse error:",e),new Error("Invalid JSON response from server")}const h=n.data||n;return this.storage&&(h.access_token&&(this.storage.accessToken=h.access_token),h.refresh_token&&(this.storage.refreshToken=h.refresh_token)),n}async refresh(e){const t=await fetch(this.refreshUrl,{method:"POST",headers:this.headers,body:JSON.stringify({refresh_token:e})});if(!t.ok){const e=await t.text().catch(()=>t.statusText);throw new Error(`Refresh failed: ${t.status} ${e}`)}let s;try{s=await t.json()}catch(e){throw console.error("❌ JSON parse error:",e),new Error("Invalid JSON response from server")}const r=s.data||s;return this.storage&&(r.access_token&&(this.storage.accessToken=r.access_token),r.refresh_token&&(this.storage.refreshToken=r.refresh_token)),s}async introspect(e=null){let t=e;if(this.storage&&!t&&(t=this.storage.accessToken),!t)throw new Error("No access token available for introspection");const s=new Headers(this.headers);s.set("Authorization",`Bearer ${t}`);const r=await fetch(this.introspectUrl,{method:"GET",headers:s});if(!r.ok){const e=await r.text().catch(()=>r.statusText);throw new Error(`Introspect failed: ${r.status} ${e}`)}let o;try{o=await r.json()}catch(e){throw console.error("❌ JSON parse error:",e),new Error("Invalid JSON response from server")}return o}}class s{constructor(e="auth",t=null){this.prefix=e,this.tenant=t}_key(e){return this.tenant?`${this.prefix}:${this.tenant}_${e}`:`${this.prefix}_${e}`}get accessToken(){return localStorage.getItem(this._key("access_token"))}set accessToken(e){null==e?localStorage.removeItem(this._key("access_token")):localStorage.setItem(this._key("access_token"),e)}get refreshToken(){return localStorage.getItem(this._key("refresh_token"))}set refreshToken(e){null==e?localStorage.removeItem(this._key("refresh_token")):localStorage.setItem(this._key("refresh_token"),e)}clear(){localStorage.removeItem(this._key("access_token")),localStorage.removeItem(this._key("refresh_token"))}}class r{constructor(e,t=null){this.client=e,this.storage=t||new s("auth",e.tenant||null)}async fetch(e,t={},s=null){const r=this.storage.accessToken,o=new Headers(t.headers||{});return r&&o.set("Authorization",`Bearer ${r}`),t.method&&"GET"!==t.method&&t.body?await this._xhrRequest(e,t,o,s):await this._fetchWithDownloadProgress(e,t,o,s)}async _fetchWithDownloadProgress(e,t,s,r){let o=await fetch(e,{...t,headers:s});if(401===o.status&&this.storage.refreshToken)try{const r=await this.client.refresh(this.storage.refreshToken);r.access_token&&(this.storage.accessToken=r.access_token),r.refresh_token&&(this.storage.refreshToken=r.refresh_token),s.set("Authorization",`Bearer ${this.storage.accessToken}`),o=await fetch(e,{...t,headers:s})}catch{throw this.storage.clear(),new Error("Unauthorized, please login again")}if(!r||!o.body)return o;const a=o.body.getReader(),n=+o.headers.get("Content-Length")||0;let h=0;const i=[];for(;;){const{done:e,value:t}=await a.read();if(e)break;i.push(t),h+=t.length,n?r(Math.round(h/n*100),h,n):r(null,h,null)}const c=new Blob(i);return new Response(c,o)}_xhrRequest(e,t,s,r){return new Promise((o,a)=>{const n=new XMLHttpRequest;n.open(t.method||"POST",e,!0);for(const[e,t]of s.entries())n.setRequestHeader(e,t);n.upload&&r&&(n.upload.onprogress=e=>{if(e.lengthComputable){const t=Math.round(e.loaded/e.total*100);r(t,e.loaded,e.total)}else r(null,e.loaded,null)}),n.onload=()=>{o(new Response(n.response,{status:n.status}))},n.onerror=()=>a(new Error("Network error")),n.send(t.body)})}}const o={AuthClient:t,AuthFetch:r,TokenStorage:s};e.AuthClient=t,e.AuthFetch=r,e.TokenStorage=s,e.default=o,Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).AuthSDK={})}(this,function(e){"use strict";class t{constructor({baseUrl:e,tenant:t=null,loginPath:s=null,refreshPath:r=null,introspectPath:o=null,headers:a={},storage:n=null}){if(!e)throw new Error("baseUrl is required");this.baseUrl=e.replace(/\/$/,"");const h=t?`/api/v1/${t}/auth`:"/api/v1/auth";this.loginUrl=this.baseUrl+(s||`${h}/login`),this.refreshUrl=this.baseUrl+(r||`${h}/refresh`),this.introspectUrl=this.baseUrl+(o||`${h}/me`),this.tenant=t,this.headers={"Content-Type":"application/json",...a},this.storage=n}async login(e,t,s=null,r={}){const o={identifier:e,password:t,...r};s&&(o.totp=s);const a=await fetch(this.loginUrl,{method:"POST",headers:this.headers,body:JSON.stringify(o)});if(!a.ok){const e=await a.text().catch(()=>a.statusText);throw new Error(`Login failed: ${a.status} ${e}`)}let n;try{n=await a.json()}catch(i){throw console.error("❌ JSON parse error:",i),new Error("Invalid JSON response from server")}const h=n.data||n;return this.storage&&(h.access_token&&(this.storage.accessToken=h.access_token),h.refresh_token&&(this.storage.refreshToken=h.refresh_token)),n}async refresh(e){const t=await fetch(this.refreshUrl,{method:"POST",headers:this.headers,body:JSON.stringify({refresh_token:e})});if(!t.ok){const e=await t.text().catch(()=>t.statusText);throw new Error(`Refresh failed: ${t.status} ${e}`)}let s;try{s=await t.json()}catch(o){throw console.error("❌ JSON parse error:",o),new Error("Invalid JSON response from server")}const r=s.data||s;return this.storage&&(r.access_token&&(this.storage.accessToken=r.access_token),r.refresh_token&&(this.storage.refreshToken=r.refresh_token)),s}async introspect(e=null){let t=e;if(this.storage&&!t&&(t=this.storage.accessToken),!t)throw new Error("No access token available for introspection");const s=new Headers(this.headers);s.set("Authorization",`Bearer ${t}`);const r=await fetch(this.introspectUrl,{method:"GET",headers:s});if(!r.ok){const e=await r.text().catch(()=>r.statusText);throw new Error(`Introspect failed: ${r.status} ${e}`)}let o;try{o=await r.json()}catch(a){throw console.error("❌ JSON parse error:",a),new Error("Invalid JSON response from server")}return o}}class s{constructor(e="auth",t=null){this.prefix=e,this.tenant=t}_key(e){return this.tenant?`${this.prefix}:${this.tenant}_${e}`:`${this.prefix}_${e}`}get accessToken(){return localStorage.getItem(this._key("access_token"))}set accessToken(e){null==e?localStorage.removeItem(this._key("access_token")):localStorage.setItem(this._key("access_token"),e)}get refreshToken(){return localStorage.getItem(this._key("refresh_token"))}set refreshToken(e){null==e?localStorage.removeItem(this._key("refresh_token")):localStorage.setItem(this._key("refresh_token"),e)}clear(){localStorage.removeItem(this._key("access_token")),localStorage.removeItem(this._key("refresh_token"))}}class r{constructor(e,t=null){this.client=e,this.storage=t||new s("auth",e.tenant||null)}async fetch(e,t={},s=null){const r=this.storage.accessToken,o=new Headers(t.headers||{});return r&&o.set("Authorization",`Bearer ${r}`),t.method&&"GET"!==t.method&&t.body?await this._xhrRequest(e,t,o,s):await this._fetchWithDownloadProgress(e,t,o,s)}async _fetchWithDownloadProgress(e,t,s,r){let o=await fetch(e,{...t,headers:s});if(401===o.status&&this.storage.refreshToken)try{const r=await this.client.refresh(this.storage.refreshToken);r.access_token&&(this.storage.accessToken=r.access_token),r.refresh_token&&(this.storage.refreshToken=r.refresh_token),s.set("Authorization",`Bearer ${this.storage.accessToken}`),o=await fetch(e,{...t,headers:s})}catch{throw this.storage.clear(),new Error("Unauthorized, please login again")}if(!r||!o.body)return o;const a=o.body.getReader(),n=+o.headers.get("Content-Length")||0;let h=0;const i=[];for(;;){const{done:e,value:t}=await a.read();if(e)break;if(i.push(t),h+=t.length,n){r(Math.round(h/n*100),h,n)}else r(null,h,null)}const c=new Blob(i);return new Response(c,o)}_xhrRequest(e,t,s,r){return new Promise((o,a)=>{const n=new XMLHttpRequest;n.open(t.method||"POST",e,!0);for(const[e,t]of s.entries())n.setRequestHeader(e,t);n.upload&&r&&(n.upload.onprogress=e=>{if(e.lengthComputable){const t=Math.round(e.loaded/e.total*100);r(t,e.loaded,e.total)}else r(null,e.loaded,null)}),n.onload=()=>{o(new Response(n.response,{status:n.status}))},n.onerror=()=>a(new Error("Network error")),n.send(t.body)})}}const o={AuthClient:t,AuthFetch:r,TokenStorage:s};e.AuthClient=t,e.AuthFetch=r,e.TokenStorage=s,e.default=o,Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface AuthClientOptions {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
tenant?: string | null;
|
|
4
|
+
loginPath?: string;
|
|
5
|
+
refreshPath?: string;
|
|
6
|
+
introspectPath?: string;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
storage?: TokenStorage | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Callback báo tiến trình (upload hoặc download) */
|
|
12
|
+
export type ProgressCallback = (
|
|
13
|
+
percent: number | null, // % (nếu biết), null nếu không xác định total
|
|
14
|
+
loaded: number, // số bytes đã tải
|
|
15
|
+
total: number | null // tổng bytes (nếu biết)
|
|
16
|
+
) => void;
|
|
17
|
+
|
|
18
|
+
export interface TokenData {
|
|
19
|
+
access_token: string;
|
|
20
|
+
refresh_token: string;
|
|
21
|
+
token_type: string;
|
|
22
|
+
expires_in: number;
|
|
23
|
+
refresh_expires_at: string;
|
|
24
|
+
message?: string;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuthResponse {
|
|
29
|
+
status: string; // "success" | "error"
|
|
30
|
+
code: number; // HTTP-like status code
|
|
31
|
+
message: string;
|
|
32
|
+
data: TokenData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TokenStorage {
|
|
36
|
+
constructor(prefix?: string, tenant?: string | null);
|
|
37
|
+
|
|
38
|
+
readonly prefix: string;
|
|
39
|
+
readonly tenant: string | null;
|
|
40
|
+
|
|
41
|
+
get accessToken(): string | null;
|
|
42
|
+
set accessToken(value: string | null);
|
|
43
|
+
|
|
44
|
+
get refreshToken(): string | null;
|
|
45
|
+
set refreshToken(value: string | null);
|
|
46
|
+
|
|
47
|
+
clear(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AuthClient {
|
|
51
|
+
constructor(options: AuthClientOptions);
|
|
52
|
+
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
tenant: string | null;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
storage: TokenStorage | null;
|
|
57
|
+
|
|
58
|
+
login(
|
|
59
|
+
identifier: string,
|
|
60
|
+
password: string,
|
|
61
|
+
clientId?: string | null,
|
|
62
|
+
extra?: Record<string, any>
|
|
63
|
+
): Promise<AuthResponse>;
|
|
64
|
+
|
|
65
|
+
refresh(refreshToken: string): Promise<AuthResponse>;
|
|
66
|
+
|
|
67
|
+
introspect(token?: string | null): Promise<AuthResponse>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class AuthFetch {
|
|
71
|
+
constructor(authClient: AuthClient, storage?: TokenStorage);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* fetch wrapper — hỗ trợ:
|
|
75
|
+
* - auto attach Bearer token
|
|
76
|
+
* - auto refresh token on 401
|
|
77
|
+
* - upload progress (XHR)
|
|
78
|
+
* - download progress (Fetch streaming)
|
|
79
|
+
*/
|
|
80
|
+
fetch(
|
|
81
|
+
input: RequestInfo | URL,
|
|
82
|
+
init?: RequestInit,
|
|
83
|
+
onProgress?: ProgressCallback
|
|
84
|
+
): Promise<Response>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
declare const _default: {
|
|
88
|
+
AuthClient: typeof AuthClient;
|
|
89
|
+
AuthFetch: typeof AuthFetch;
|
|
90
|
+
TokenStorage: typeof TokenStorage;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export default _default;
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gh-platform/auth-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "VanillaJS Auth SDK for GH Platform",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/auth-sdk.umd.js",
|
|
7
|
+
"module": "dist/auth-sdk.es.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/auth-sdk.es.js",
|
|
13
|
+
"require": "./dist/auth-sdk.umd.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "vite build && cp src/index.d.ts dist/index.d.ts && terser dist/auth-sdk.umd.js -o dist/auth-sdk.min.js --compress --mangle"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"vite": "^5.0.0",
|
|
21
|
+
"terser": "^5.20.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// src/client.js
|
|
2
|
+
export default class AuthClient {
|
|
3
|
+
/**
|
|
4
|
+
* options:
|
|
5
|
+
* - baseUrl (required) e.g. "https://auth.example.com"
|
|
6
|
+
* - tenant (optional) e.g. "demo" -> will target /api/v1/{tenant}/auth/*
|
|
7
|
+
* - loginPath / refreshPath / introspectPath (optional overrides)
|
|
8
|
+
* - headers (optional)
|
|
9
|
+
*/
|
|
10
|
+
constructor({
|
|
11
|
+
baseUrl,
|
|
12
|
+
tenant = null,
|
|
13
|
+
loginPath = null,
|
|
14
|
+
refreshPath = null,
|
|
15
|
+
introspectPath = null,
|
|
16
|
+
headers = {},
|
|
17
|
+
storage = null, // <-- thêm storage vào constructor
|
|
18
|
+
}) {
|
|
19
|
+
if (!baseUrl) throw new Error("baseUrl is required");
|
|
20
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
21
|
+
|
|
22
|
+
// default path builder: tenant-aware
|
|
23
|
+
const prefix = tenant ? `/api/v1/${tenant}/auth` : `/api/v1/auth`;
|
|
24
|
+
|
|
25
|
+
this.loginUrl = this.baseUrl + (loginPath || `${prefix}/login`);
|
|
26
|
+
this.refreshUrl = this.baseUrl + (refreshPath || `${prefix}/refresh`);
|
|
27
|
+
this.introspectUrl = this.baseUrl + (introspectPath || `${prefix}/me`);
|
|
28
|
+
|
|
29
|
+
this.tenant = tenant; // keep for storage prefix
|
|
30
|
+
this.headers = { "Content-Type": "application/json", ...headers };
|
|
31
|
+
this.storage = storage; // <-- store storage instance
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Login payload uses identifier + password (+ optional totp)
|
|
36
|
+
* extra is optional object for backward-compat or extra fields
|
|
37
|
+
*/
|
|
38
|
+
async login(identifier, password, totp = null, extra = {}) {
|
|
39
|
+
const body = {
|
|
40
|
+
identifier,
|
|
41
|
+
password,
|
|
42
|
+
...extra,
|
|
43
|
+
};
|
|
44
|
+
if (totp) body.totp = totp;
|
|
45
|
+
|
|
46
|
+
const res = await fetch(this.loginUrl, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: this.headers,
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const text = await res.text().catch(() => res.statusText);
|
|
54
|
+
throw new Error(`Login failed: ${res.status} ${text}`);
|
|
55
|
+
}
|
|
56
|
+
let json; // 👈 MUST DECLARE
|
|
57
|
+
try {
|
|
58
|
+
json = await res.json();
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error("❌ JSON parse error:", e);
|
|
61
|
+
throw new Error("Invalid JSON response from server");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -----------------------------
|
|
65
|
+
// 🔥 Auto-save tokens
|
|
66
|
+
// -----------------------------
|
|
67
|
+
const data = json.data || json;
|
|
68
|
+
if (this.storage) {
|
|
69
|
+
if (data.access_token) this.storage.accessToken = data.access_token;
|
|
70
|
+
if (data.refresh_token) this.storage.refreshToken = data.refresh_token;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return json;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async refresh(refreshToken) {
|
|
77
|
+
const res = await fetch(this.refreshUrl, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: this.headers,
|
|
80
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const text = await res.text().catch(() => res.statusText);
|
|
84
|
+
throw new Error(`Refresh failed: ${res.status} ${text}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let json; // 👈 MUST DECLARE
|
|
88
|
+
try {
|
|
89
|
+
json = await res.json();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error("❌ JSON parse error:", e);
|
|
92
|
+
throw new Error("Invalid JSON response from server");
|
|
93
|
+
}
|
|
94
|
+
const data = json.data || json;
|
|
95
|
+
// -----------------------------
|
|
96
|
+
// 🔥 Auto-save refreshed tokens
|
|
97
|
+
// -----------------------------
|
|
98
|
+
if (this.storage) {
|
|
99
|
+
if (data.access_token) this.storage.accessToken = data.access_token;
|
|
100
|
+
if (data.refresh_token) this.storage.refreshToken = data.refresh_token;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return json;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async introspect(token = null) {
|
|
107
|
+
// -----------------------------
|
|
108
|
+
// 🔥 Auto-get token from storage
|
|
109
|
+
// -----------------------------
|
|
110
|
+
let finalToken = token;
|
|
111
|
+
|
|
112
|
+
if (this.storage && !finalToken) {
|
|
113
|
+
finalToken = this.storage.accessToken;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!finalToken) {
|
|
117
|
+
throw new Error("No access token available for introspection");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const headers = new Headers(this.headers);
|
|
121
|
+
headers.set("Authorization", `Bearer ${finalToken}`);
|
|
122
|
+
const res = await fetch(this.introspectUrl, {
|
|
123
|
+
method: "GET",
|
|
124
|
+
headers: headers,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
const text = await res.text().catch(() => res.statusText);
|
|
129
|
+
throw new Error(`Introspect failed: ${res.status} ${text}`);
|
|
130
|
+
}
|
|
131
|
+
let json; // 👈 MUST DECLARE
|
|
132
|
+
try {
|
|
133
|
+
json = await res.json();
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error("❌ JSON parse error:", e);
|
|
136
|
+
throw new Error("Invalid JSON response from server");
|
|
137
|
+
}
|
|
138
|
+
return json;
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface AuthClientOptions {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
tenant?: string | null;
|
|
4
|
+
loginPath?: string;
|
|
5
|
+
refreshPath?: string;
|
|
6
|
+
introspectPath?: string;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
storage?: TokenStorage | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Callback báo tiến trình (upload hoặc download) */
|
|
12
|
+
export type ProgressCallback = (
|
|
13
|
+
percent: number | null, // % (nếu biết), null nếu không xác định total
|
|
14
|
+
loaded: number, // số bytes đã tải
|
|
15
|
+
total: number | null // tổng bytes (nếu biết)
|
|
16
|
+
) => void;
|
|
17
|
+
|
|
18
|
+
export interface TokenData {
|
|
19
|
+
access_token: string;
|
|
20
|
+
refresh_token: string;
|
|
21
|
+
token_type: string;
|
|
22
|
+
expires_in: number;
|
|
23
|
+
refresh_expires_at: string;
|
|
24
|
+
message?: string;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AuthResponse {
|
|
29
|
+
status: string; // "success" | "error"
|
|
30
|
+
code: number; // HTTP-like status code
|
|
31
|
+
message: string;
|
|
32
|
+
data: TokenData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TokenStorage {
|
|
36
|
+
constructor(prefix?: string, tenant?: string | null);
|
|
37
|
+
|
|
38
|
+
readonly prefix: string;
|
|
39
|
+
readonly tenant: string | null;
|
|
40
|
+
|
|
41
|
+
get accessToken(): string | null;
|
|
42
|
+
set accessToken(value: string | null);
|
|
43
|
+
|
|
44
|
+
get refreshToken(): string | null;
|
|
45
|
+
set refreshToken(value: string | null);
|
|
46
|
+
|
|
47
|
+
clear(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class AuthClient {
|
|
51
|
+
constructor(options: AuthClientOptions);
|
|
52
|
+
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
tenant: string | null;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
storage: TokenStorage | null;
|
|
57
|
+
|
|
58
|
+
login(
|
|
59
|
+
identifier: string,
|
|
60
|
+
password: string,
|
|
61
|
+
clientId?: string | null,
|
|
62
|
+
extra?: Record<string, any>
|
|
63
|
+
): Promise<AuthResponse>;
|
|
64
|
+
|
|
65
|
+
refresh(refreshToken: string): Promise<AuthResponse>;
|
|
66
|
+
|
|
67
|
+
introspect(token?: string | null): Promise<AuthResponse>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class AuthFetch {
|
|
71
|
+
constructor(authClient: AuthClient, storage?: TokenStorage);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* fetch wrapper — hỗ trợ:
|
|
75
|
+
* - auto attach Bearer token
|
|
76
|
+
* - auto refresh token on 401
|
|
77
|
+
* - upload progress (XHR)
|
|
78
|
+
* - download progress (Fetch streaming)
|
|
79
|
+
*/
|
|
80
|
+
fetch(
|
|
81
|
+
input: RequestInfo | URL,
|
|
82
|
+
init?: RequestInit,
|
|
83
|
+
onProgress?: ProgressCallback
|
|
84
|
+
): Promise<Response>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
declare const _default: {
|
|
88
|
+
AuthClient: typeof AuthClient;
|
|
89
|
+
AuthFetch: typeof AuthFetch;
|
|
90
|
+
TokenStorage: typeof TokenStorage;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export default _default;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// src/middleware.js
|
|
2
|
+
import { TokenStorage } from "./storage.js";
|
|
3
|
+
|
|
4
|
+
export class AuthFetch {
|
|
5
|
+
constructor(authClient, storage = null) {
|
|
6
|
+
this.client = authClient;
|
|
7
|
+
this.storage = storage || new TokenStorage("auth", authClient.tenant || null);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* fetchWithProgress:
|
|
12
|
+
* - input: URL
|
|
13
|
+
* - options: fetch options
|
|
14
|
+
* - onProgress: callback(percent, loaded, total)
|
|
15
|
+
*/
|
|
16
|
+
async fetch(input, options = {}, onProgress = null) {
|
|
17
|
+
const token = this.storage.accessToken;
|
|
18
|
+
const headers = new Headers(options.headers || {});
|
|
19
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
20
|
+
|
|
21
|
+
// Nếu có body → dùng XHR để GET progress upload
|
|
22
|
+
if (options.method && options.method !== "GET" && options.body) {
|
|
23
|
+
return await this._xhrRequest(input, options, headers, onProgress);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// GET/STREAM → dùng fetch
|
|
27
|
+
return await this._fetchWithDownloadProgress(input, options, headers, onProgress);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ------------------ DOWNLOAD PROGRESS ------------------
|
|
31
|
+
async _fetchWithDownloadProgress(input, options, headers, onProgress) {
|
|
32
|
+
let response = await fetch(input, { ...options, headers });
|
|
33
|
+
|
|
34
|
+
// Handle 401 refresh
|
|
35
|
+
if (response.status === 401 && this.storage.refreshToken) {
|
|
36
|
+
try {
|
|
37
|
+
const newTokens = await this.client.refresh(this.storage.refreshToken);
|
|
38
|
+
if (newTokens.access_token) this.storage.accessToken = newTokens.access_token;
|
|
39
|
+
if (newTokens.refresh_token) this.storage.refreshToken = newTokens.refresh_token;
|
|
40
|
+
|
|
41
|
+
headers.set("Authorization", `Bearer ${this.storage.accessToken}`);
|
|
42
|
+
response = await fetch(input, { ...options, headers });
|
|
43
|
+
} catch {
|
|
44
|
+
this.storage.clear();
|
|
45
|
+
throw new Error("Unauthorized, please login again");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Nếu không cần track progress
|
|
50
|
+
if (!onProgress || !response.body) return response;
|
|
51
|
+
|
|
52
|
+
// Stream reader
|
|
53
|
+
const reader = response.body.getReader();
|
|
54
|
+
const contentLength = +response.headers.get("Content-Length") || 0;
|
|
55
|
+
|
|
56
|
+
let loaded = 0;
|
|
57
|
+
const chunks = [];
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done) break;
|
|
62
|
+
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
loaded += value.length;
|
|
65
|
+
|
|
66
|
+
if (contentLength) {
|
|
67
|
+
const percent = Math.round((loaded / contentLength) * 100);
|
|
68
|
+
onProgress(percent, loaded, contentLength);
|
|
69
|
+
} else {
|
|
70
|
+
onProgress(null, loaded, null); // không biết tổng → chỉ báo loaded
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const blob = new Blob(chunks);
|
|
75
|
+
return new Response(blob, response);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ------------------ UPLOAD PROGRESS ------------------
|
|
79
|
+
_xhrRequest(url, options, headers, onProgress) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const xhr = new XMLHttpRequest();
|
|
82
|
+
xhr.open(options.method || "POST", url, true);
|
|
83
|
+
|
|
84
|
+
// set headers
|
|
85
|
+
for (const [k, v] of headers.entries()) {
|
|
86
|
+
xhr.setRequestHeader(k, v);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Upload progress
|
|
90
|
+
if (xhr.upload && onProgress) {
|
|
91
|
+
xhr.upload.onprogress = (e) => {
|
|
92
|
+
if (e.lengthComputable) {
|
|
93
|
+
const percent = Math.round((e.loaded / e.total) * 100);
|
|
94
|
+
onProgress(percent, e.loaded, e.total);
|
|
95
|
+
} else {
|
|
96
|
+
onProgress(null, e.loaded, null);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
xhr.onload = () => {
|
|
102
|
+
resolve(new Response(xhr.response, { status: xhr.status }));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
xhr.onerror = () => reject(new Error("Network error"));
|
|
106
|
+
xhr.send(options.body);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export default AuthFetch;
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/storage.js
|
|
2
|
+
export class TokenStorage {
|
|
3
|
+
/**
|
|
4
|
+
* prefix: base prefix (default "auth")
|
|
5
|
+
* tenant: optional tenant string -> final key = `${prefix}:${tenant}_access_token`
|
|
6
|
+
*/
|
|
7
|
+
constructor(prefix = "auth", tenant = null) {
|
|
8
|
+
this.prefix = prefix;
|
|
9
|
+
this.tenant = tenant;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_key(name) {
|
|
13
|
+
// include tenant to isolate tokens between tenants if provided
|
|
14
|
+
return this.tenant ? `${this.prefix}:${this.tenant}_${name}` : `${this.prefix}_${name}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get accessToken() {
|
|
18
|
+
return localStorage.getItem(this._key("access_token"));
|
|
19
|
+
}
|
|
20
|
+
set accessToken(val) {
|
|
21
|
+
if (val === null || val === undefined) {
|
|
22
|
+
localStorage.removeItem(this._key("access_token"));
|
|
23
|
+
} else {
|
|
24
|
+
localStorage.setItem(this._key("access_token"), val);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get refreshToken() {
|
|
29
|
+
return localStorage.getItem(this._key("refresh_token"));
|
|
30
|
+
}
|
|
31
|
+
set refreshToken(val) {
|
|
32
|
+
if (val === null || val === undefined) {
|
|
33
|
+
localStorage.removeItem(this._key("refresh_token"));
|
|
34
|
+
} else {
|
|
35
|
+
localStorage.setItem(this._key("refresh_token"), val);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear() {
|
|
40
|
+
localStorage.removeItem(this._key("access_token"));
|
|
41
|
+
localStorage.removeItem(this._key("refresh_token"));
|
|
42
|
+
}
|
|
43
|
+
}
|
package/vite.config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
build: {
|
|
5
|
+
lib: {
|
|
6
|
+
entry: "src/index.js",
|
|
7
|
+
name: "AuthSDK", // window.AuthSDK.*
|
|
8
|
+
fileName: (format) => `auth-sdk.${format}.js`,
|
|
9
|
+
formats: ["es", "umd"],
|
|
10
|
+
},
|
|
11
|
+
rollupOptions: {
|
|
12
|
+
output: {
|
|
13
|
+
exports: "named",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
minify: "terser",
|
|
17
|
+
outDir: "dist",
|
|
18
|
+
},
|
|
19
|
+
});
|