@rx-ted/packages-auth 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/LICENSE +21 -0
- package/README.md +194 -0
- package/dist/index.cjs +193 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +149 -0
- package/dist/index.js +184 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ben
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @rx-ted/packages-auth
|
|
2
|
+
|
|
3
|
+
Auth 客户端库,提供内存 Token 存储、刷新去重、Pinia 状态管理,与 `@rx-ted/packages-http-client` 配合使用。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @rx-ted/packages-auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
需要 peer 依赖:`vue`、`pinia`。
|
|
12
|
+
|
|
13
|
+
## 快速开始
|
|
14
|
+
|
|
15
|
+
### 1. 创建认证提供者并注入 HTTP 客户端
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { setAuthProvider } from '@rx-ted/packages-http-client';
|
|
19
|
+
import { tokenStorage, refreshHandler, createAuthProvider } from '@rx-ted/packages-auth';
|
|
20
|
+
|
|
21
|
+
setAuthProvider(
|
|
22
|
+
createAuthProvider({
|
|
23
|
+
tokenStorage,
|
|
24
|
+
refreshHandler,
|
|
25
|
+
baseUrl: '/api/v1',
|
|
26
|
+
onAuthFailure: () => {
|
|
27
|
+
window.location.href = '/login';
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 2. 创建 Pinia Store
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { createAuthStore } from '@rx-ted/packages-auth';
|
|
37
|
+
|
|
38
|
+
export const useSessionStore = createAuthStore('session');
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
在组件中使用:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { useSessionStore } from '@/stores/session';
|
|
45
|
+
|
|
46
|
+
const store = useSessionStore();
|
|
47
|
+
|
|
48
|
+
// 登录
|
|
49
|
+
await store.login({ email: 'user@example.com', password: '***' });
|
|
50
|
+
|
|
51
|
+
// 启动时自动刷新 token
|
|
52
|
+
await store.bootstrap();
|
|
53
|
+
|
|
54
|
+
// 登出
|
|
55
|
+
await store.logout();
|
|
56
|
+
|
|
57
|
+
// 响应式状态
|
|
58
|
+
console.log(store.isAuthenticated);
|
|
59
|
+
console.log(store.user);
|
|
60
|
+
console.log(store.loading);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
### `TokenStorage`
|
|
66
|
+
|
|
67
|
+
内存 token 存储,跨标签页同步(`BroadcastChannel`):
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
import { TokenStorage, tokenStorage } from '@rx-ted/packages-auth';
|
|
71
|
+
|
|
72
|
+
const storage = new TokenStorage();
|
|
73
|
+
|
|
74
|
+
storage.token = 'my-token'; // 写入(同步广播到其他标签页)
|
|
75
|
+
console.log(storage.token); // 读取
|
|
76
|
+
|
|
77
|
+
const unsub = storage.subscribe((token) => {
|
|
78
|
+
console.log('token changed:', token);
|
|
79
|
+
});
|
|
80
|
+
unsub(); // 取消订阅
|
|
81
|
+
|
|
82
|
+
storage.destroy(); // 清理
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
通常直接使用默认单例 `tokenStorage`。
|
|
86
|
+
|
|
87
|
+
### `RefreshQueue` / `refreshHandler`
|
|
88
|
+
|
|
89
|
+
Token 刷新去重队列,防止并发刷新:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { refreshHandler } from '@rx-ted/packages-auth';
|
|
93
|
+
|
|
94
|
+
// refreshToken 方法已内嵌在 createAuthStore 中
|
|
95
|
+
const newToken = await store.refreshToken();
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
内部实现为 `RefreshQueue`(来自 `@rx-ted/packages-http-client`),确保同一时间只发一次刷新请求,多个等待者共享结果。
|
|
99
|
+
|
|
100
|
+
### `createAuthProvider(config)`
|
|
101
|
+
|
|
102
|
+
创建与 `@rx-ted/packages-http-client` 兼容的 `AuthProvider`:
|
|
103
|
+
|
|
104
|
+
| 参数 | 类型 | 必填 | 说明 |
|
|
105
|
+
| ----------------- | ---------------------------- | ---- | ------------------------------ |
|
|
106
|
+
| `tokenStorage` | `TokenStorage` | 是 | Token 存储实例 |
|
|
107
|
+
| `refreshHandler` | `RefreshHandler` | 是 | 刷新去重实例 |
|
|
108
|
+
| `baseUrl` | `string` | 否 | API 基础路径,默认 `/api/v1` |
|
|
109
|
+
| `onAuthFailure` | `(reason?: string) => void` | 否 | 认证失败回调(跳转登录页等) |
|
|
110
|
+
|
|
111
|
+
返回的 `AuthProvider` 包含 `getToken`、`refreshToken`、`onAuthFailure` 方法。
|
|
112
|
+
|
|
113
|
+
刷新流程:
|
|
114
|
+
|
|
115
|
+
- `POST {baseUrl}/auth/refresh` with `credentials: 'include'`
|
|
116
|
+
- 识别 token 被盗用(`TOKEN_STOLEN` / `reuse detected`),在 `sessionStorage` 标记
|
|
117
|
+
- 刷新失败返回 `null`
|
|
118
|
+
|
|
119
|
+
### `createAuthStore(id)`
|
|
120
|
+
|
|
121
|
+
创建 Pinia auth store:
|
|
122
|
+
|
|
123
|
+
| 状态 / 方法 | 类型 | 说明 |
|
|
124
|
+
| ------------------ | --------------------------------- | ---------------------------- |
|
|
125
|
+
| `user` | `Ref<AuthUser \| null>` | 当前用户 |
|
|
126
|
+
| `token` | `Ref<string \| null>` | Access Token |
|
|
127
|
+
| `loading` | `Ref<boolean>` | 请求中 |
|
|
128
|
+
| `isAuthenticated` | `ComputedRef<boolean>` | 是否已认证 |
|
|
129
|
+
| `login(params)` | `(params) => Promise<LoginResponse>` | 登录(POST /auth/login) |
|
|
130
|
+
| `logout()` | `() => Promise<void>` | 登出(POST /auth/logout) |
|
|
131
|
+
| `refreshToken()` | `() => Promise<string \| null>` | 刷新 token |
|
|
132
|
+
| `bootstrap()` | `() => Promise<void>` | 应用启动时自动恢复认证状态 |
|
|
133
|
+
| `clearSession()` | `() => void` | 清除本地 session |
|
|
134
|
+
|
|
135
|
+
**`bootstrap` 流程:**
|
|
136
|
+
|
|
137
|
+
1. 检查 `tokenStorage` 中是否有 token
|
|
138
|
+
2. 若无,尝试调用 `POST /auth/refresh`
|
|
139
|
+
3. 若有 token 或刷新成功,调用 `GET /auth/me` 获取用户信息
|
|
140
|
+
4. 失败则清除 token
|
|
141
|
+
|
|
142
|
+
### 类型
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
interface AuthUser {
|
|
146
|
+
id: string;
|
|
147
|
+
email: string;
|
|
148
|
+
name?: string;
|
|
149
|
+
// ...
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface LoginParams {
|
|
153
|
+
email: string;
|
|
154
|
+
password: string;
|
|
155
|
+
// ...
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface LoginResponse {
|
|
159
|
+
accessToken: string;
|
|
160
|
+
user: AuthUser;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface TokenPair {
|
|
164
|
+
accessToken: string;
|
|
165
|
+
refreshToken: string;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 架构
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
┌─────────────────────┐
|
|
173
|
+
│ Application │
|
|
174
|
+
├─────────────────────┤
|
|
175
|
+
│ createAuthStore() │ ← Pinia Store (login/logout/bootstrap)
|
|
176
|
+
├─────────────────────┤
|
|
177
|
+
│ createAuthProvider │ ← 适配 http-client 的 AuthProvider
|
|
178
|
+
├────────┬────────────┤
|
|
179
|
+
│ │ │
|
|
180
|
+
│ TokenStorage │ RefreshQueue (from http-client)
|
|
181
|
+
│ (内存 + Broadcast │ (刷新去重)
|
|
182
|
+
│ Channel 跨页同步) │
|
|
183
|
+
└────────┴────────────┘
|
|
184
|
+
│
|
|
185
|
+
▼
|
|
186
|
+
fetch(/api/v1/auth/*)
|
|
187
|
+
或 @rx-ted/packages-http-client
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## 测试
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pnpm test
|
|
194
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var packagesHttpClient = require('@rx-ted/packages-http-client');
|
|
4
|
+
var pinia = require('pinia');
|
|
5
|
+
var vue = require('vue');
|
|
6
|
+
|
|
7
|
+
// src/tokenStorage.ts
|
|
8
|
+
var AUTH_CHANNEL = "auth:token";
|
|
9
|
+
var TokenStorage = class {
|
|
10
|
+
_token = null;
|
|
11
|
+
listeners = /* @__PURE__ */ new Set();
|
|
12
|
+
channel = null;
|
|
13
|
+
constructor() {
|
|
14
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
15
|
+
try {
|
|
16
|
+
this.channel = new BroadcastChannel(AUTH_CHANNEL);
|
|
17
|
+
this.channel.onmessage = (event) => {
|
|
18
|
+
if (event.data?.type === "token_updated") {
|
|
19
|
+
this._token = event.data.token ?? null;
|
|
20
|
+
this.notify();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
get token() {
|
|
28
|
+
return this._token;
|
|
29
|
+
}
|
|
30
|
+
set token(value) {
|
|
31
|
+
this._token = value;
|
|
32
|
+
this.channel?.postMessage({ type: "token_updated", token: value });
|
|
33
|
+
this.notify();
|
|
34
|
+
}
|
|
35
|
+
subscribe(listener) {
|
|
36
|
+
this.listeners.add(listener);
|
|
37
|
+
return () => this.listeners.delete(listener);
|
|
38
|
+
}
|
|
39
|
+
notify() {
|
|
40
|
+
for (const fn of this.listeners) fn(this._token);
|
|
41
|
+
}
|
|
42
|
+
destroy() {
|
|
43
|
+
this.listeners.clear();
|
|
44
|
+
this.channel?.close();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var tokenStorage = new TokenStorage();
|
|
48
|
+
var refreshHandler = new packagesHttpClient.RefreshQueue();
|
|
49
|
+
|
|
50
|
+
// src/authProvider.ts
|
|
51
|
+
var STOLEN_KEY = "auth:stolen";
|
|
52
|
+
function createAuthProvider(config) {
|
|
53
|
+
const { tokenStorage: tokenStorage2, refreshHandler: refreshHandler2, baseUrl = "/api/v1", onAuthFailure } = config;
|
|
54
|
+
async function doRefresh() {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`${baseUrl}/auth/refresh`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
credentials: "include",
|
|
59
|
+
headers: { Accept: "application/json" }
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const body = await res.json().catch(() => ({}));
|
|
63
|
+
const message = body?.message ?? "";
|
|
64
|
+
if (message.includes("reuse detected") || message.includes("TOKEN_STOLEN")) {
|
|
65
|
+
if (typeof sessionStorage !== "undefined") {
|
|
66
|
+
sessionStorage.setItem(STOLEN_KEY, "1");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
tokenStorage2.token = data.accessToken;
|
|
73
|
+
return data.accessToken;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
getToken: () => tokenStorage2.token,
|
|
80
|
+
refreshToken: () => refreshHandler2.refresh(doRefresh),
|
|
81
|
+
onAuthFailure: (reason) => {
|
|
82
|
+
tokenStorage2.token = null;
|
|
83
|
+
onAuthFailure?.(reason);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var AUTH_API = "/api/v1/auth";
|
|
88
|
+
async function silentRefresh() {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${AUTH_API}/refresh`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
credentials: "include",
|
|
93
|
+
headers: { Accept: "application/json" }
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) return null;
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
tokenStorage.token = data.accessToken;
|
|
98
|
+
return data.accessToken;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function createAuthStore(id) {
|
|
104
|
+
return pinia.defineStore(id, () => {
|
|
105
|
+
const user = vue.ref(null);
|
|
106
|
+
const loading = vue.ref(false);
|
|
107
|
+
const token = vue.ref(tokenStorage.token);
|
|
108
|
+
const isAuthenticated = vue.computed(() => !!token.value && !!user.value);
|
|
109
|
+
tokenStorage.subscribe((t) => {
|
|
110
|
+
token.value = t;
|
|
111
|
+
if (!t) user.value = null;
|
|
112
|
+
});
|
|
113
|
+
async function login(params) {
|
|
114
|
+
loading.value = true;
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${AUTH_API}/login`, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(params),
|
|
120
|
+
credentials: "include"
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
const err = await res.json();
|
|
124
|
+
throw new Error(err.message ?? "Login failed");
|
|
125
|
+
}
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
tokenStorage.token = data.accessToken;
|
|
128
|
+
token.value = data.accessToken;
|
|
129
|
+
user.value = data.user;
|
|
130
|
+
return data;
|
|
131
|
+
} finally {
|
|
132
|
+
loading.value = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function refreshToken() {
|
|
136
|
+
return refreshHandler.refresh(silentRefresh);
|
|
137
|
+
}
|
|
138
|
+
async function bootstrap() {
|
|
139
|
+
let token2 = tokenStorage.token;
|
|
140
|
+
if (!token2) {
|
|
141
|
+
token2 = await refreshToken();
|
|
142
|
+
}
|
|
143
|
+
if (token2) {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`${AUTH_API}/me`, {
|
|
146
|
+
headers: { Authorization: `Bearer ${token2}` }
|
|
147
|
+
});
|
|
148
|
+
if (res.ok) {
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
user.value = data.data ?? data;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
}
|
|
155
|
+
tokenStorage.token = null;
|
|
156
|
+
user.value = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function logout() {
|
|
160
|
+
try {
|
|
161
|
+
await fetch(`${AUTH_API}/logout`, { method: "POST", credentials: "include" });
|
|
162
|
+
} catch {
|
|
163
|
+
}
|
|
164
|
+
tokenStorage.token = null;
|
|
165
|
+
user.value = null;
|
|
166
|
+
}
|
|
167
|
+
function clearSession() {
|
|
168
|
+
tokenStorage.token = null;
|
|
169
|
+
user.value = null;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
user,
|
|
173
|
+
token,
|
|
174
|
+
loading,
|
|
175
|
+
isAuthenticated,
|
|
176
|
+
login,
|
|
177
|
+
logout,
|
|
178
|
+
refreshToken,
|
|
179
|
+
bootstrap,
|
|
180
|
+
clearSession
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Object.defineProperty(exports, "RefreshQueue", {
|
|
186
|
+
enumerable: true,
|
|
187
|
+
get: function () { return packagesHttpClient.RefreshQueue; }
|
|
188
|
+
});
|
|
189
|
+
exports.TokenStorage = TokenStorage;
|
|
190
|
+
exports.createAuthProvider = createAuthProvider;
|
|
191
|
+
exports.createAuthStore = createAuthStore;
|
|
192
|
+
exports.refreshHandler = refreshHandler;
|
|
193
|
+
exports.tokenStorage = tokenStorage;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { RefreshQueue } from '@rx-ted/packages-http-client';
|
|
2
|
+
export { RefreshQueue as RefreshHandler, RefreshQueue } from '@rx-ted/packages-http-client';
|
|
3
|
+
import * as pinia from 'pinia';
|
|
4
|
+
import * as vue from 'vue';
|
|
5
|
+
|
|
6
|
+
interface AuthUser {
|
|
7
|
+
userId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
preferredLocale: string;
|
|
10
|
+
roles: string[];
|
|
11
|
+
permissions: string[];
|
|
12
|
+
tokenVersion: number;
|
|
13
|
+
lastLoginAt: string | null;
|
|
14
|
+
nickname: string | null;
|
|
15
|
+
avatarUrl: string | null;
|
|
16
|
+
}
|
|
17
|
+
interface LoginParams {
|
|
18
|
+
username: string;
|
|
19
|
+
password: string;
|
|
20
|
+
}
|
|
21
|
+
interface TokenPair {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
expiresIn: string;
|
|
24
|
+
}
|
|
25
|
+
interface LoginResponse extends TokenPair {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
user: AuthUser;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare class TokenStorage {
|
|
31
|
+
private _token;
|
|
32
|
+
private listeners;
|
|
33
|
+
private channel;
|
|
34
|
+
constructor();
|
|
35
|
+
get token(): string | null;
|
|
36
|
+
set token(value: string | null);
|
|
37
|
+
subscribe(listener: (token: string | null) => void): () => void;
|
|
38
|
+
private notify;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
declare const tokenStorage: TokenStorage;
|
|
42
|
+
|
|
43
|
+
declare const refreshHandler: RefreshQueue;
|
|
44
|
+
|
|
45
|
+
interface AuthProviderConfig {
|
|
46
|
+
tokenStorage: TokenStorage;
|
|
47
|
+
refreshHandler: RefreshQueue;
|
|
48
|
+
baseUrl?: string;
|
|
49
|
+
onAuthFailure?: (reason?: string) => void;
|
|
50
|
+
}
|
|
51
|
+
declare function createAuthProvider(config: AuthProviderConfig): {
|
|
52
|
+
getToken: () => string | null;
|
|
53
|
+
refreshToken: () => Promise<string | null>;
|
|
54
|
+
onAuthFailure: (reason?: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
declare function createAuthStore(id: string): pinia.StoreDefinition<string, Pick<{
|
|
58
|
+
user: vue.Ref<{
|
|
59
|
+
userId: string;
|
|
60
|
+
username: string;
|
|
61
|
+
preferredLocale: string;
|
|
62
|
+
roles: string[];
|
|
63
|
+
permissions: string[];
|
|
64
|
+
tokenVersion: number;
|
|
65
|
+
lastLoginAt: string | null;
|
|
66
|
+
nickname: string | null;
|
|
67
|
+
avatarUrl: string | null;
|
|
68
|
+
} | null, AuthUser | {
|
|
69
|
+
userId: string;
|
|
70
|
+
username: string;
|
|
71
|
+
preferredLocale: string;
|
|
72
|
+
roles: string[];
|
|
73
|
+
permissions: string[];
|
|
74
|
+
tokenVersion: number;
|
|
75
|
+
lastLoginAt: string | null;
|
|
76
|
+
nickname: string | null;
|
|
77
|
+
avatarUrl: string | null;
|
|
78
|
+
} | null>;
|
|
79
|
+
token: vue.Ref<string | null, string | null>;
|
|
80
|
+
loading: vue.Ref<boolean, boolean>;
|
|
81
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
82
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
83
|
+
logout: () => Promise<void>;
|
|
84
|
+
refreshToken: () => Promise<string | null>;
|
|
85
|
+
bootstrap: () => Promise<void>;
|
|
86
|
+
clearSession: () => void;
|
|
87
|
+
}, "user" | "token" | "loading">, Pick<{
|
|
88
|
+
user: vue.Ref<{
|
|
89
|
+
userId: string;
|
|
90
|
+
username: string;
|
|
91
|
+
preferredLocale: string;
|
|
92
|
+
roles: string[];
|
|
93
|
+
permissions: string[];
|
|
94
|
+
tokenVersion: number;
|
|
95
|
+
lastLoginAt: string | null;
|
|
96
|
+
nickname: string | null;
|
|
97
|
+
avatarUrl: string | null;
|
|
98
|
+
} | null, AuthUser | {
|
|
99
|
+
userId: string;
|
|
100
|
+
username: string;
|
|
101
|
+
preferredLocale: string;
|
|
102
|
+
roles: string[];
|
|
103
|
+
permissions: string[];
|
|
104
|
+
tokenVersion: number;
|
|
105
|
+
lastLoginAt: string | null;
|
|
106
|
+
nickname: string | null;
|
|
107
|
+
avatarUrl: string | null;
|
|
108
|
+
} | null>;
|
|
109
|
+
token: vue.Ref<string | null, string | null>;
|
|
110
|
+
loading: vue.Ref<boolean, boolean>;
|
|
111
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
112
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
113
|
+
logout: () => Promise<void>;
|
|
114
|
+
refreshToken: () => Promise<string | null>;
|
|
115
|
+
bootstrap: () => Promise<void>;
|
|
116
|
+
clearSession: () => void;
|
|
117
|
+
}, "isAuthenticated">, Pick<{
|
|
118
|
+
user: vue.Ref<{
|
|
119
|
+
userId: string;
|
|
120
|
+
username: string;
|
|
121
|
+
preferredLocale: string;
|
|
122
|
+
roles: string[];
|
|
123
|
+
permissions: string[];
|
|
124
|
+
tokenVersion: number;
|
|
125
|
+
lastLoginAt: string | null;
|
|
126
|
+
nickname: string | null;
|
|
127
|
+
avatarUrl: string | null;
|
|
128
|
+
} | null, AuthUser | {
|
|
129
|
+
userId: string;
|
|
130
|
+
username: string;
|
|
131
|
+
preferredLocale: string;
|
|
132
|
+
roles: string[];
|
|
133
|
+
permissions: string[];
|
|
134
|
+
tokenVersion: number;
|
|
135
|
+
lastLoginAt: string | null;
|
|
136
|
+
nickname: string | null;
|
|
137
|
+
avatarUrl: string | null;
|
|
138
|
+
} | null>;
|
|
139
|
+
token: vue.Ref<string | null, string | null>;
|
|
140
|
+
loading: vue.Ref<boolean, boolean>;
|
|
141
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
142
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
143
|
+
logout: () => Promise<void>;
|
|
144
|
+
refreshToken: () => Promise<string | null>;
|
|
145
|
+
bootstrap: () => Promise<void>;
|
|
146
|
+
clearSession: () => void;
|
|
147
|
+
}, "login" | "logout" | "refreshToken" | "bootstrap" | "clearSession">>;
|
|
148
|
+
|
|
149
|
+
export { type AuthProviderConfig, type AuthUser, type LoginParams, type LoginResponse, type TokenPair, TokenStorage, createAuthProvider, createAuthStore, refreshHandler, tokenStorage };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { RefreshQueue } from '@rx-ted/packages-http-client';
|
|
2
|
+
export { RefreshQueue as RefreshHandler, RefreshQueue } from '@rx-ted/packages-http-client';
|
|
3
|
+
import * as pinia from 'pinia';
|
|
4
|
+
import * as vue from 'vue';
|
|
5
|
+
|
|
6
|
+
interface AuthUser {
|
|
7
|
+
userId: string;
|
|
8
|
+
username: string;
|
|
9
|
+
preferredLocale: string;
|
|
10
|
+
roles: string[];
|
|
11
|
+
permissions: string[];
|
|
12
|
+
tokenVersion: number;
|
|
13
|
+
lastLoginAt: string | null;
|
|
14
|
+
nickname: string | null;
|
|
15
|
+
avatarUrl: string | null;
|
|
16
|
+
}
|
|
17
|
+
interface LoginParams {
|
|
18
|
+
username: string;
|
|
19
|
+
password: string;
|
|
20
|
+
}
|
|
21
|
+
interface TokenPair {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
expiresIn: string;
|
|
24
|
+
}
|
|
25
|
+
interface LoginResponse extends TokenPair {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
user: AuthUser;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare class TokenStorage {
|
|
31
|
+
private _token;
|
|
32
|
+
private listeners;
|
|
33
|
+
private channel;
|
|
34
|
+
constructor();
|
|
35
|
+
get token(): string | null;
|
|
36
|
+
set token(value: string | null);
|
|
37
|
+
subscribe(listener: (token: string | null) => void): () => void;
|
|
38
|
+
private notify;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
}
|
|
41
|
+
declare const tokenStorage: TokenStorage;
|
|
42
|
+
|
|
43
|
+
declare const refreshHandler: RefreshQueue;
|
|
44
|
+
|
|
45
|
+
interface AuthProviderConfig {
|
|
46
|
+
tokenStorage: TokenStorage;
|
|
47
|
+
refreshHandler: RefreshQueue;
|
|
48
|
+
baseUrl?: string;
|
|
49
|
+
onAuthFailure?: (reason?: string) => void;
|
|
50
|
+
}
|
|
51
|
+
declare function createAuthProvider(config: AuthProviderConfig): {
|
|
52
|
+
getToken: () => string | null;
|
|
53
|
+
refreshToken: () => Promise<string | null>;
|
|
54
|
+
onAuthFailure: (reason?: string) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
declare function createAuthStore(id: string): pinia.StoreDefinition<string, Pick<{
|
|
58
|
+
user: vue.Ref<{
|
|
59
|
+
userId: string;
|
|
60
|
+
username: string;
|
|
61
|
+
preferredLocale: string;
|
|
62
|
+
roles: string[];
|
|
63
|
+
permissions: string[];
|
|
64
|
+
tokenVersion: number;
|
|
65
|
+
lastLoginAt: string | null;
|
|
66
|
+
nickname: string | null;
|
|
67
|
+
avatarUrl: string | null;
|
|
68
|
+
} | null, AuthUser | {
|
|
69
|
+
userId: string;
|
|
70
|
+
username: string;
|
|
71
|
+
preferredLocale: string;
|
|
72
|
+
roles: string[];
|
|
73
|
+
permissions: string[];
|
|
74
|
+
tokenVersion: number;
|
|
75
|
+
lastLoginAt: string | null;
|
|
76
|
+
nickname: string | null;
|
|
77
|
+
avatarUrl: string | null;
|
|
78
|
+
} | null>;
|
|
79
|
+
token: vue.Ref<string | null, string | null>;
|
|
80
|
+
loading: vue.Ref<boolean, boolean>;
|
|
81
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
82
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
83
|
+
logout: () => Promise<void>;
|
|
84
|
+
refreshToken: () => Promise<string | null>;
|
|
85
|
+
bootstrap: () => Promise<void>;
|
|
86
|
+
clearSession: () => void;
|
|
87
|
+
}, "user" | "token" | "loading">, Pick<{
|
|
88
|
+
user: vue.Ref<{
|
|
89
|
+
userId: string;
|
|
90
|
+
username: string;
|
|
91
|
+
preferredLocale: string;
|
|
92
|
+
roles: string[];
|
|
93
|
+
permissions: string[];
|
|
94
|
+
tokenVersion: number;
|
|
95
|
+
lastLoginAt: string | null;
|
|
96
|
+
nickname: string | null;
|
|
97
|
+
avatarUrl: string | null;
|
|
98
|
+
} | null, AuthUser | {
|
|
99
|
+
userId: string;
|
|
100
|
+
username: string;
|
|
101
|
+
preferredLocale: string;
|
|
102
|
+
roles: string[];
|
|
103
|
+
permissions: string[];
|
|
104
|
+
tokenVersion: number;
|
|
105
|
+
lastLoginAt: string | null;
|
|
106
|
+
nickname: string | null;
|
|
107
|
+
avatarUrl: string | null;
|
|
108
|
+
} | null>;
|
|
109
|
+
token: vue.Ref<string | null, string | null>;
|
|
110
|
+
loading: vue.Ref<boolean, boolean>;
|
|
111
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
112
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
113
|
+
logout: () => Promise<void>;
|
|
114
|
+
refreshToken: () => Promise<string | null>;
|
|
115
|
+
bootstrap: () => Promise<void>;
|
|
116
|
+
clearSession: () => void;
|
|
117
|
+
}, "isAuthenticated">, Pick<{
|
|
118
|
+
user: vue.Ref<{
|
|
119
|
+
userId: string;
|
|
120
|
+
username: string;
|
|
121
|
+
preferredLocale: string;
|
|
122
|
+
roles: string[];
|
|
123
|
+
permissions: string[];
|
|
124
|
+
tokenVersion: number;
|
|
125
|
+
lastLoginAt: string | null;
|
|
126
|
+
nickname: string | null;
|
|
127
|
+
avatarUrl: string | null;
|
|
128
|
+
} | null, AuthUser | {
|
|
129
|
+
userId: string;
|
|
130
|
+
username: string;
|
|
131
|
+
preferredLocale: string;
|
|
132
|
+
roles: string[];
|
|
133
|
+
permissions: string[];
|
|
134
|
+
tokenVersion: number;
|
|
135
|
+
lastLoginAt: string | null;
|
|
136
|
+
nickname: string | null;
|
|
137
|
+
avatarUrl: string | null;
|
|
138
|
+
} | null>;
|
|
139
|
+
token: vue.Ref<string | null, string | null>;
|
|
140
|
+
loading: vue.Ref<boolean, boolean>;
|
|
141
|
+
isAuthenticated: vue.ComputedRef<boolean>;
|
|
142
|
+
login: (params: LoginParams) => Promise<LoginResponse>;
|
|
143
|
+
logout: () => Promise<void>;
|
|
144
|
+
refreshToken: () => Promise<string | null>;
|
|
145
|
+
bootstrap: () => Promise<void>;
|
|
146
|
+
clearSession: () => void;
|
|
147
|
+
}, "login" | "logout" | "refreshToken" | "bootstrap" | "clearSession">>;
|
|
148
|
+
|
|
149
|
+
export { type AuthProviderConfig, type AuthUser, type LoginParams, type LoginResponse, type TokenPair, TokenStorage, createAuthProvider, createAuthStore, refreshHandler, tokenStorage };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { RefreshQueue } from '@rx-ted/packages-http-client';
|
|
2
|
+
export { RefreshQueue } from '@rx-ted/packages-http-client';
|
|
3
|
+
import { defineStore } from 'pinia';
|
|
4
|
+
import { ref, computed } from 'vue';
|
|
5
|
+
|
|
6
|
+
// src/tokenStorage.ts
|
|
7
|
+
var AUTH_CHANNEL = "auth:token";
|
|
8
|
+
var TokenStorage = class {
|
|
9
|
+
_token = null;
|
|
10
|
+
listeners = /* @__PURE__ */ new Set();
|
|
11
|
+
channel = null;
|
|
12
|
+
constructor() {
|
|
13
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
14
|
+
try {
|
|
15
|
+
this.channel = new BroadcastChannel(AUTH_CHANNEL);
|
|
16
|
+
this.channel.onmessage = (event) => {
|
|
17
|
+
if (event.data?.type === "token_updated") {
|
|
18
|
+
this._token = event.data.token ?? null;
|
|
19
|
+
this.notify();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
get token() {
|
|
27
|
+
return this._token;
|
|
28
|
+
}
|
|
29
|
+
set token(value) {
|
|
30
|
+
this._token = value;
|
|
31
|
+
this.channel?.postMessage({ type: "token_updated", token: value });
|
|
32
|
+
this.notify();
|
|
33
|
+
}
|
|
34
|
+
subscribe(listener) {
|
|
35
|
+
this.listeners.add(listener);
|
|
36
|
+
return () => this.listeners.delete(listener);
|
|
37
|
+
}
|
|
38
|
+
notify() {
|
|
39
|
+
for (const fn of this.listeners) fn(this._token);
|
|
40
|
+
}
|
|
41
|
+
destroy() {
|
|
42
|
+
this.listeners.clear();
|
|
43
|
+
this.channel?.close();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var tokenStorage = new TokenStorage();
|
|
47
|
+
var refreshHandler = new RefreshQueue();
|
|
48
|
+
|
|
49
|
+
// src/authProvider.ts
|
|
50
|
+
var STOLEN_KEY = "auth:stolen";
|
|
51
|
+
function createAuthProvider(config) {
|
|
52
|
+
const { tokenStorage: tokenStorage2, refreshHandler: refreshHandler2, baseUrl = "/api/v1", onAuthFailure } = config;
|
|
53
|
+
async function doRefresh() {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${baseUrl}/auth/refresh`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
credentials: "include",
|
|
58
|
+
headers: { Accept: "application/json" }
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const body = await res.json().catch(() => ({}));
|
|
62
|
+
const message = body?.message ?? "";
|
|
63
|
+
if (message.includes("reuse detected") || message.includes("TOKEN_STOLEN")) {
|
|
64
|
+
if (typeof sessionStorage !== "undefined") {
|
|
65
|
+
sessionStorage.setItem(STOLEN_KEY, "1");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const data = await res.json();
|
|
71
|
+
tokenStorage2.token = data.accessToken;
|
|
72
|
+
return data.accessToken;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
getToken: () => tokenStorage2.token,
|
|
79
|
+
refreshToken: () => refreshHandler2.refresh(doRefresh),
|
|
80
|
+
onAuthFailure: (reason) => {
|
|
81
|
+
tokenStorage2.token = null;
|
|
82
|
+
onAuthFailure?.(reason);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
var AUTH_API = "/api/v1/auth";
|
|
87
|
+
async function silentRefresh() {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${AUTH_API}/refresh`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
credentials: "include",
|
|
92
|
+
headers: { Accept: "application/json" }
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) return null;
|
|
95
|
+
const data = await res.json();
|
|
96
|
+
tokenStorage.token = data.accessToken;
|
|
97
|
+
return data.accessToken;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function createAuthStore(id) {
|
|
103
|
+
return defineStore(id, () => {
|
|
104
|
+
const user = ref(null);
|
|
105
|
+
const loading = ref(false);
|
|
106
|
+
const token = ref(tokenStorage.token);
|
|
107
|
+
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
|
108
|
+
tokenStorage.subscribe((t) => {
|
|
109
|
+
token.value = t;
|
|
110
|
+
if (!t) user.value = null;
|
|
111
|
+
});
|
|
112
|
+
async function login(params) {
|
|
113
|
+
loading.value = true;
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${AUTH_API}/login`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify(params),
|
|
119
|
+
credentials: "include"
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const err = await res.json();
|
|
123
|
+
throw new Error(err.message ?? "Login failed");
|
|
124
|
+
}
|
|
125
|
+
const data = await res.json();
|
|
126
|
+
tokenStorage.token = data.accessToken;
|
|
127
|
+
token.value = data.accessToken;
|
|
128
|
+
user.value = data.user;
|
|
129
|
+
return data;
|
|
130
|
+
} finally {
|
|
131
|
+
loading.value = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function refreshToken() {
|
|
135
|
+
return refreshHandler.refresh(silentRefresh);
|
|
136
|
+
}
|
|
137
|
+
async function bootstrap() {
|
|
138
|
+
let token2 = tokenStorage.token;
|
|
139
|
+
if (!token2) {
|
|
140
|
+
token2 = await refreshToken();
|
|
141
|
+
}
|
|
142
|
+
if (token2) {
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(`${AUTH_API}/me`, {
|
|
145
|
+
headers: { Authorization: `Bearer ${token2}` }
|
|
146
|
+
});
|
|
147
|
+
if (res.ok) {
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
user.value = data.data ?? data;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
}
|
|
154
|
+
tokenStorage.token = null;
|
|
155
|
+
user.value = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function logout() {
|
|
159
|
+
try {
|
|
160
|
+
await fetch(`${AUTH_API}/logout`, { method: "POST", credentials: "include" });
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
tokenStorage.token = null;
|
|
164
|
+
user.value = null;
|
|
165
|
+
}
|
|
166
|
+
function clearSession() {
|
|
167
|
+
tokenStorage.token = null;
|
|
168
|
+
user.value = null;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
user,
|
|
172
|
+
token,
|
|
173
|
+
loading,
|
|
174
|
+
isAuthenticated,
|
|
175
|
+
login,
|
|
176
|
+
logout,
|
|
177
|
+
refreshToken,
|
|
178
|
+
bootstrap,
|
|
179
|
+
clearSession
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { TokenStorage, createAuthProvider, createAuthStore, refreshHandler, tokenStorage };
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rx-ted/packages-auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Auth client: in-memory token storage, refresh queue, Pinia store factory",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"author": "rx-ted",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@rx-ted/packages-http-client": "^1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"pinia": "^3.0.4",
|
|
28
|
+
"vue": "^3.5.34"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"pinia": "^3.0.4",
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
|
+
"typescript": "^6.0.3",
|
|
34
|
+
"vitest": "^4.1.7",
|
|
35
|
+
"vue": "^3.5.35"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"dev": "tsup --watch",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest",
|
|
43
|
+
"test:coverage": "vitest run --coverage"
|
|
44
|
+
}
|
|
45
|
+
}
|