@55387.ai/uniauth-server 1.2.0 → 1.2.3
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/README.md +74 -171
- package/package.json +5 -1
- package/src/index.ts +0 -581
- package/src/server.test.ts +0 -231
- package/tsconfig.json +0 -15
package/README.md
CHANGED
|
@@ -1,244 +1,151 @@
|
|
|
1
1
|
# @55387.ai/uniauth-server
|
|
2
2
|
|
|
3
|
-
UniAuth
|
|
3
|
+
> UniAuth Backend SDK — Token verification & middleware for Node.js servers.
|
|
4
|
+
>
|
|
5
|
+
> UniAuth 后端 SDK — Node.js 服务端令牌验证和中间件。
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
**Version / 版本:** 1.2.2
|
|
8
|
+
|
|
9
|
+
## Install / 安装
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
12
|
npm install @55387.ai/uniauth-server
|
|
9
|
-
# or
|
|
13
|
+
# or / 或
|
|
10
14
|
pnpm add @55387.ai/uniauth-server
|
|
11
15
|
```
|
|
12
16
|
|
|
13
|
-
## 快速开始
|
|
17
|
+
## Quick Start / 快速开始
|
|
14
18
|
|
|
15
19
|
```typescript
|
|
16
20
|
import { UniAuthServer } from '@55387.ai/uniauth-server';
|
|
17
21
|
|
|
18
22
|
const auth = new UniAuthServer({
|
|
19
23
|
baseUrl: 'https://sso.55387.xyz',
|
|
20
|
-
clientId:
|
|
21
|
-
clientSecret:
|
|
24
|
+
clientId: process.env.UNIAUTH_CLIENT_ID!,
|
|
25
|
+
clientSecret: process.env.UNIAUTH_CLIENT_SECRET!,
|
|
22
26
|
});
|
|
23
27
|
|
|
24
|
-
// 验证令牌
|
|
28
|
+
// Verify token / 验证令牌
|
|
25
29
|
const payload = await auth.verifyToken(accessToken);
|
|
26
30
|
console.log('User ID:', payload.sub);
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
##
|
|
33
|
+
## Middleware / 中间件
|
|
34
|
+
|
|
35
|
+
### Express
|
|
30
36
|
|
|
31
37
|
```typescript
|
|
32
38
|
import express from 'express';
|
|
33
|
-
import { UniAuthServer } from '@55387.ai/uniauth-server';
|
|
34
|
-
|
|
35
39
|
const app = express();
|
|
36
|
-
const auth = new UniAuthServer({ ... });
|
|
37
40
|
|
|
38
|
-
// 保护 API 路由
|
|
39
41
|
app.use('/api/*', auth.middleware());
|
|
40
42
|
|
|
41
|
-
// 在路由中使用用户信息
|
|
42
43
|
app.get('/api/profile', (req, res) => {
|
|
43
44
|
res.json({ user: req.user, payload: req.authPayload });
|
|
44
45
|
});
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
### Hono
|
|
48
49
|
|
|
49
50
|
```typescript
|
|
50
51
|
import { Hono } from 'hono';
|
|
51
|
-
import { UniAuthServer } from '@55387.ai/uniauth-server';
|
|
52
|
-
|
|
53
52
|
const app = new Hono();
|
|
54
|
-
const auth = new UniAuthServer({ ... });
|
|
55
53
|
|
|
56
|
-
// 保护 API 路由
|
|
57
54
|
app.use('/api/*', auth.honoMiddleware());
|
|
58
55
|
|
|
59
|
-
// 在路由中使用用户信息
|
|
60
56
|
app.get('/api/profile', (c) => {
|
|
61
|
-
|
|
62
|
-
return c.json({ user });
|
|
57
|
+
return c.json({ user: c.get('user') });
|
|
63
58
|
});
|
|
64
59
|
```
|
|
65
60
|
|
|
66
|
-
## SSO
|
|
61
|
+
## SSO Backend Proxy / SSO 后端代理
|
|
67
62
|
|
|
68
|
-
|
|
63
|
+
When your app is a **Confidential Client**, token exchange must happen on the server.
|
|
69
64
|
|
|
70
|
-
|
|
65
|
+
当应用配置为 **机密客户端** 时,Token 交换必须在服务端完成。
|
|
71
66
|
|
|
72
67
|
```
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
68
|
+
User → Frontend → /api/auth/login → Backend → redirect to UniAuth SSO
|
|
69
|
+
↓
|
|
70
|
+
User ← Frontend ← redirect ← Backend (set cookie) ← SSO callback
|
|
71
|
+
↑
|
|
72
|
+
Backend exchanges code with client_secret
|
|
78
73
|
```
|
|
79
74
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
| 端点 | URL |
|
|
83
|
-
|------|-----|
|
|
84
|
-
| 授权端点 | `https://sso.55387.xyz/api/v1/oauth2/authorize` |
|
|
85
|
-
| Token 端点 | `https://sso.55387.xyz/api/v1/oauth2/token` |
|
|
86
|
-
| 用户信息端点 | `https://sso.55387.xyz/api/v1/oauth2/userinfo` |
|
|
87
|
-
|
|
88
|
-
### 实现示例(Hono)
|
|
89
|
-
|
|
90
|
-
```typescript
|
|
91
|
-
import { Hono } from 'hono';
|
|
92
|
-
import { setCookie, getCookie } from 'hono/cookie';
|
|
93
|
-
|
|
94
|
-
const app = new Hono();
|
|
95
|
-
|
|
96
|
-
// 登录端点 - 重定向到 SSO
|
|
97
|
-
app.get('/api/auth/login', (c) => {
|
|
98
|
-
const origin = c.req.header('origin') || 'http://localhost:3000';
|
|
99
|
-
const redirectUri = `${origin}/api/auth/callback`;
|
|
100
|
-
|
|
101
|
-
const params = new URLSearchParams({
|
|
102
|
-
client_id: process.env.UNIAUTH_CLIENT_ID,
|
|
103
|
-
redirect_uri: redirectUri,
|
|
104
|
-
response_type: 'code',
|
|
105
|
-
scope: 'openid profile email phone',
|
|
106
|
-
state: generateRandomState(), // 生成随机 state 防止 CSRF
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
return c.redirect(`https://sso.55387.xyz/api/v1/oauth2/authorize?${params}`);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// 回调端点 - 交换 Token
|
|
113
|
-
app.get('/api/auth/callback', async (c) => {
|
|
114
|
-
const code = c.req.query('code');
|
|
115
|
-
const origin = c.req.header('referer')?.replace(/\/api\/auth\/callback.*$/, '') || 'http://localhost:3000';
|
|
116
|
-
|
|
117
|
-
// 用授权码交换 Token
|
|
118
|
-
const response = await fetch('https://sso.55387.xyz/api/v1/oauth2/token', {
|
|
119
|
-
method: 'POST',
|
|
120
|
-
headers: { 'Content-Type': 'application/json' },
|
|
121
|
-
body: JSON.stringify({
|
|
122
|
-
client_id: process.env.UNIAUTH_CLIENT_ID,
|
|
123
|
-
client_secret: process.env.UNIAUTH_CLIENT_SECRET,
|
|
124
|
-
code,
|
|
125
|
-
grant_type: 'authorization_code',
|
|
126
|
-
redirect_uri: `${origin}/api/auth/callback`,
|
|
127
|
-
}),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const { access_token, id_token } = await response.json();
|
|
131
|
-
|
|
132
|
-
// 将 Token 存储到 httpOnly Cookie
|
|
133
|
-
setCookie(c, 'auth_token', id_token, {
|
|
134
|
-
httpOnly: true,
|
|
135
|
-
secure: true,
|
|
136
|
-
sameSite: 'Lax',
|
|
137
|
-
maxAge: 60 * 60 * 24 * 7, // 7 天
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
return c.redirect('/');
|
|
141
|
-
});
|
|
75
|
+
See full implementation: [AI Integration Guide](../../docs/AI_INTEGRATION_GUIDE.md#2b-backend-proxy-confidential-client)
|
|
142
76
|
|
|
143
|
-
|
|
144
|
-
app.get('/api/auth/status', async (c) => {
|
|
145
|
-
const token = getCookie(c, 'auth_token');
|
|
146
|
-
if (!token) {
|
|
147
|
-
return c.json({ authenticated: false });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// 验证 Token
|
|
151
|
-
try {
|
|
152
|
-
const payload = await auth.verifyToken(token);
|
|
153
|
-
return c.json({ authenticated: true, userId: payload.sub });
|
|
154
|
-
} catch {
|
|
155
|
-
return c.json({ authenticated: false });
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
```
|
|
77
|
+
完整实现见: [集成指南](../../docs/AI_INTEGRATION_GUIDE.md#2b-backend-proxy-confidential-client)
|
|
159
78
|
|
|
160
|
-
|
|
79
|
+
## Token Introspection / 令牌内省
|
|
161
80
|
|
|
162
|
-
|
|
163
|
-
// 触发登录
|
|
164
|
-
const handleLogin = () => {
|
|
165
|
-
window.location.href = '/api/auth/login';
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
// 检查登录状态
|
|
169
|
-
const checkAuth = async () => {
|
|
170
|
-
const response = await fetch('/api/auth/status', { credentials: 'include' });
|
|
171
|
-
const data = await response.json();
|
|
172
|
-
return data.authenticated;
|
|
173
|
-
};
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## OAuth2 Token Introspection (RFC 7662)
|
|
81
|
+
RFC 7662 compliant token introspection:
|
|
177
82
|
|
|
178
83
|
```typescript
|
|
179
|
-
// 内省令牌(资源服务器标准验证方式)
|
|
180
84
|
const result = await auth.introspectToken(accessToken);
|
|
181
85
|
|
|
182
86
|
if (result.active) {
|
|
183
|
-
console.log('
|
|
184
|
-
console.log('
|
|
185
|
-
console.log('权限:', result.scope);
|
|
186
|
-
} else {
|
|
187
|
-
console.log('Token 无效或已过期');
|
|
87
|
+
console.log('User:', result.sub);
|
|
88
|
+
console.log('Scope:', result.scope);
|
|
188
89
|
}
|
|
189
90
|
```
|
|
190
91
|
|
|
191
|
-
## API 参考
|
|
92
|
+
## API Reference / API 参考
|
|
192
93
|
|
|
193
|
-
###
|
|
94
|
+
### Config / 配置
|
|
194
95
|
|
|
195
96
|
```typescript
|
|
196
97
|
interface UniAuthServerConfig {
|
|
197
|
-
baseUrl: string; // UniAuth
|
|
198
|
-
clientId: string; // OAuth2
|
|
199
|
-
clientSecret: string; // OAuth2
|
|
200
|
-
jwtPublicKey?: string; // JWT
|
|
98
|
+
baseUrl: string; // UniAuth server URL
|
|
99
|
+
clientId: string; // OAuth2 client ID
|
|
100
|
+
clientSecret: string; // OAuth2 client secret
|
|
101
|
+
jwtPublicKey?: string; // JWT public key (local verification)
|
|
201
102
|
}
|
|
202
103
|
```
|
|
203
104
|
|
|
204
|
-
### 方法
|
|
105
|
+
### Methods / 方法
|
|
106
|
+
|
|
107
|
+
| Method | Description / 说明 |
|
|
108
|
+
|--------|-----------|
|
|
109
|
+
| `verifyToken(token)` | Verify access token / 验证访问令牌 |
|
|
110
|
+
| `introspectToken(token)` | RFC 7662 introspection / 令牌内省 |
|
|
111
|
+
| `isTokenActive(token)` | Check if token is active / 检查令牌状态 |
|
|
112
|
+
| `getUser(userId)` | Get user info / 获取用户信息 |
|
|
113
|
+
| `middleware()` | Express middleware / Express 中间件 |
|
|
114
|
+
| `honoMiddleware()` | Hono middleware / Hono 中间件 |
|
|
115
|
+
| `clearCache()` | Clear token cache / 清除令牌缓存 |
|
|
205
116
|
|
|
206
|
-
|
|
207
|
-
|------|------|
|
|
208
|
-
| `verifyToken(token)` | 验证访问令牌 |
|
|
209
|
-
| `introspectToken(token)` | RFC 7662 令牌内省 |
|
|
210
|
-
| `isTokenActive(token)` | 检查令牌是否有效 |
|
|
211
|
-
| `getUser(userId)` | 获取用户信息 |
|
|
212
|
-
| `middleware()` | Express/Connect 中间件 |
|
|
213
|
-
| `honoMiddleware()` | Hono 中间件 |
|
|
214
|
-
| `clearCache()` | 清除令牌缓存 |
|
|
117
|
+
### Token Verification Flow / 令牌验证流程
|
|
215
118
|
|
|
216
|
-
|
|
119
|
+
```
|
|
120
|
+
verifyToken(token)
|
|
121
|
+
│
|
|
122
|
+
├─ 1. POST /api/v1/auth/verify (App Key + Secret)
|
|
123
|
+
│ ↓ success → return payload
|
|
124
|
+
│ ↓ 404 or network error
|
|
125
|
+
│
|
|
126
|
+
├─ 2. POST /api/v1/oauth2/introspect (Basic Auth, RFC 7662)
|
|
127
|
+
│ ↓ active:true → return payload
|
|
128
|
+
│ ↓ fail
|
|
129
|
+
│
|
|
130
|
+
└─ 3. Local JWT verification (if jwtPublicKey configured)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Types / 类型
|
|
217
134
|
|
|
218
135
|
```typescript
|
|
219
136
|
interface TokenPayload {
|
|
220
|
-
sub: string;
|
|
221
|
-
iss?: string;
|
|
222
|
-
aud?: string | string[];
|
|
223
|
-
exp: number;
|
|
224
|
-
iat: number;
|
|
225
|
-
scope?: string;
|
|
226
|
-
phone?: string;
|
|
227
|
-
email?: string;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
interface UserInfo {
|
|
231
|
-
id: string;
|
|
232
|
-
phone?: string;
|
|
233
|
-
email?: string;
|
|
234
|
-
nickname?: string;
|
|
235
|
-
avatar_url?: string;
|
|
236
|
-
phone_verified?: boolean;
|
|
237
|
-
email_verified?: boolean;
|
|
137
|
+
sub: string; // User ID
|
|
138
|
+
iss?: string; // Issuer
|
|
139
|
+
aud?: string | string[]; // Audience
|
|
140
|
+
exp: number; // Expiration
|
|
141
|
+
iat: number; // Issued at
|
|
142
|
+
scope?: string; // Scopes
|
|
143
|
+
phone?: string; // Phone number
|
|
144
|
+
email?: string; // Email
|
|
238
145
|
}
|
|
239
146
|
```
|
|
240
147
|
|
|
241
|
-
## 错误处理
|
|
148
|
+
## Error Handling / 错误处理
|
|
242
149
|
|
|
243
150
|
```typescript
|
|
244
151
|
import { ServerAuthError, ServerErrorCode } from '@55387.ai/uniauth-server';
|
|
@@ -248,12 +155,8 @@ try {
|
|
|
248
155
|
} catch (error) {
|
|
249
156
|
if (error instanceof ServerAuthError) {
|
|
250
157
|
switch (error.code) {
|
|
251
|
-
case ServerErrorCode.INVALID_TOKEN:
|
|
252
|
-
|
|
253
|
-
break;
|
|
254
|
-
case ServerErrorCode.TOKEN_EXPIRED:
|
|
255
|
-
// 令牌已过期
|
|
256
|
-
break;
|
|
158
|
+
case ServerErrorCode.INVALID_TOKEN: // Invalid / 令牌无效
|
|
159
|
+
case ServerErrorCode.TOKEN_EXPIRED: // Expired / 令牌过期
|
|
257
160
|
}
|
|
258
161
|
}
|
|
259
162
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@55387.ai/uniauth-server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "UniAuth Server SDK - Token verification for Node.js backends",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.mjs",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
9
13
|
"exports": {
|
|
10
14
|
".": {
|
|
11
15
|
"types": "./dist/index.d.ts",
|
package/src/index.ts
DELETED
|
@@ -1,581 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* UniAuth Server SDK
|
|
3
|
-
* 统一认证后端 SDK
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* ```typescript
|
|
7
|
-
* import { UniAuthServer } from '@55387.ai/uniauth-server';
|
|
8
|
-
*
|
|
9
|
-
* const auth = new UniAuthServer({
|
|
10
|
-
* baseUrl: 'https://auth.example.com',
|
|
11
|
-
* clientId: 'your-client-id',
|
|
12
|
-
* clientSecret: 'your-client-secret',
|
|
13
|
-
* });
|
|
14
|
-
*
|
|
15
|
-
* // Verify token
|
|
16
|
-
* const payload = await auth.verifyToken(accessToken);
|
|
17
|
-
*
|
|
18
|
-
* // Introspect token (RFC 7662)
|
|
19
|
-
* const introspectResult = await auth.introspectToken(accessToken);
|
|
20
|
-
*
|
|
21
|
-
* // Express middleware
|
|
22
|
-
* app.use('/api/*', auth.middleware());
|
|
23
|
-
*
|
|
24
|
-
* // Hono middleware
|
|
25
|
-
* app.use('/api/*', auth.honoMiddleware());
|
|
26
|
-
* ```
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import * as jose from 'jose';
|
|
30
|
-
|
|
31
|
-
// ============================================
|
|
32
|
-
// Types
|
|
33
|
-
// ============================================
|
|
34
|
-
|
|
35
|
-
export interface UniAuthServerConfig {
|
|
36
|
-
/** API base URL */
|
|
37
|
-
baseUrl: string;
|
|
38
|
-
/** OAuth2 Client ID (also used as appKey) */
|
|
39
|
-
clientId: string;
|
|
40
|
-
/** OAuth2 Client Secret (also used as appSecret) */
|
|
41
|
-
clientSecret: string;
|
|
42
|
-
/** JWT public key (optional, for local verification) */
|
|
43
|
-
jwtPublicKey?: string;
|
|
44
|
-
/** @deprecated Use clientId instead */
|
|
45
|
-
appKey?: string;
|
|
46
|
-
/** @deprecated Use clientSecret instead */
|
|
47
|
-
appSecret?: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface TokenPayload {
|
|
51
|
-
/** User ID or Client ID (for M2M) */
|
|
52
|
-
sub: string;
|
|
53
|
-
/** Issuer */
|
|
54
|
-
iss?: string;
|
|
55
|
-
/** Audience */
|
|
56
|
-
aud?: string | string[];
|
|
57
|
-
/** Issued at timestamp */
|
|
58
|
-
iat: number;
|
|
59
|
-
/** Expiration timestamp */
|
|
60
|
-
exp: number;
|
|
61
|
-
/** Scopes */
|
|
62
|
-
scope?: string;
|
|
63
|
-
/** Authorized party (client_id that requested this token) */
|
|
64
|
-
azp?: string;
|
|
65
|
-
/** Phone number (optional) */
|
|
66
|
-
phone?: string;
|
|
67
|
-
/** Email address (optional) */
|
|
68
|
-
email?: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface UserInfo {
|
|
72
|
-
id: string;
|
|
73
|
-
phone?: string | null;
|
|
74
|
-
email?: string | null;
|
|
75
|
-
nickname?: string | null;
|
|
76
|
-
avatar_url?: string | null;
|
|
77
|
-
phone_verified?: boolean;
|
|
78
|
-
email_verified?: boolean;
|
|
79
|
-
created_at?: string;
|
|
80
|
-
updated_at?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export interface VerifyResult {
|
|
84
|
-
valid: boolean;
|
|
85
|
-
payload?: TokenPayload;
|
|
86
|
-
error?: string;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* RFC 7662 Token Introspection Response
|
|
91
|
-
* 令牌内省响应
|
|
92
|
-
*/
|
|
93
|
-
export interface IntrospectionResult {
|
|
94
|
-
/** Whether the token is active */
|
|
95
|
-
active: boolean;
|
|
96
|
-
/** Scopes associated with this token */
|
|
97
|
-
scope?: string;
|
|
98
|
-
/** Client ID that requested the token */
|
|
99
|
-
client_id?: string;
|
|
100
|
-
/** Username or user identifier */
|
|
101
|
-
username?: string;
|
|
102
|
-
/** Token type (usually "Bearer") */
|
|
103
|
-
token_type?: string;
|
|
104
|
-
/** Expiration timestamp */
|
|
105
|
-
exp?: number;
|
|
106
|
-
/** Issued at timestamp */
|
|
107
|
-
iat?: number;
|
|
108
|
-
/** Not before timestamp */
|
|
109
|
-
nbf?: number;
|
|
110
|
-
/** Subject (user ID or client ID) */
|
|
111
|
-
sub?: string;
|
|
112
|
-
/** Audience */
|
|
113
|
-
aud?: string | string[];
|
|
114
|
-
/** Issuer */
|
|
115
|
-
iss?: string;
|
|
116
|
-
/** JWT ID */
|
|
117
|
-
jti?: string;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Error codes for UniAuth Server SDK
|
|
122
|
-
* UniAuth 服务端 SDK 错误码
|
|
123
|
-
*/
|
|
124
|
-
export const ServerErrorCode = {
|
|
125
|
-
INVALID_TOKEN: 'INVALID_TOKEN',
|
|
126
|
-
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
|
127
|
-
VERIFICATION_FAILED: 'VERIFICATION_FAILED',
|
|
128
|
-
USER_NOT_FOUND: 'USER_NOT_FOUND',
|
|
129
|
-
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
130
|
-
NO_PUBLIC_KEY: 'NO_PUBLIC_KEY',
|
|
131
|
-
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
132
|
-
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
133
|
-
} as const;
|
|
134
|
-
|
|
135
|
-
export type ServerErrorCodeType = typeof ServerErrorCode[keyof typeof ServerErrorCode];
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Custom error class for server SDK
|
|
139
|
-
* 服务端 SDK 自定义错误类
|
|
140
|
-
*/
|
|
141
|
-
export class ServerAuthError extends Error {
|
|
142
|
-
code: ServerErrorCodeType | string;
|
|
143
|
-
statusCode: number;
|
|
144
|
-
|
|
145
|
-
constructor(code: ServerErrorCodeType | string, message: string, statusCode: number = 401) {
|
|
146
|
-
super(message);
|
|
147
|
-
this.name = 'ServerAuthError';
|
|
148
|
-
this.code = code;
|
|
149
|
-
this.statusCode = statusCode;
|
|
150
|
-
|
|
151
|
-
if (Error.captureStackTrace) {
|
|
152
|
-
Error.captureStackTrace(this, ServerAuthError);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// API Response types
|
|
158
|
-
interface ApiErrorResponse {
|
|
159
|
-
success: false;
|
|
160
|
-
error?: {
|
|
161
|
-
code?: string;
|
|
162
|
-
message?: string;
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
interface ApiSuccessResponse<T> {
|
|
167
|
-
success: true;
|
|
168
|
-
data: T;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Express-compatible request/response types
|
|
172
|
-
interface ExpressRequest {
|
|
173
|
-
headers: Record<string, string | string[] | undefined>;
|
|
174
|
-
user?: UserInfo;
|
|
175
|
-
authPayload?: TokenPayload;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
interface ExpressResponse {
|
|
179
|
-
status(code: number): ExpressResponse;
|
|
180
|
-
json(data: unknown): void;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
type NextFunction = (error?: Error) => void;
|
|
184
|
-
|
|
185
|
-
// Hono-compatible types
|
|
186
|
-
interface HonoContext {
|
|
187
|
-
req: {
|
|
188
|
-
header(name: string): string | undefined;
|
|
189
|
-
};
|
|
190
|
-
set(key: string, value: unknown): void;
|
|
191
|
-
get(key: string): unknown;
|
|
192
|
-
json(data: unknown, status?: number): Response;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
type HonoMiddlewareHandler = (c: HonoContext, next: () => Promise<void>) => Promise<Response | void>;
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* UniAuth Server SDK
|
|
199
|
-
* 统一认证后端 SDK
|
|
200
|
-
*/
|
|
201
|
-
export class UniAuthServer {
|
|
202
|
-
private config: UniAuthServerConfig;
|
|
203
|
-
private tokenCache: Map<string, { payload: TokenPayload; expiresAt: number }> = new Map();
|
|
204
|
-
|
|
205
|
-
constructor(config: UniAuthServerConfig) {
|
|
206
|
-
// Support both new naming (clientId/clientSecret) and legacy (appKey/appSecret)
|
|
207
|
-
this.config = {
|
|
208
|
-
...config,
|
|
209
|
-
clientId: config.clientId || config.appKey || '',
|
|
210
|
-
clientSecret: config.clientSecret || config.appSecret || '',
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ============================================
|
|
215
|
-
// Token Verification
|
|
216
|
-
// ============================================
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Verify access token
|
|
220
|
-
* 验证访问令牌
|
|
221
|
-
*
|
|
222
|
-
* @param token - JWT access token
|
|
223
|
-
* @returns Token payload if valid
|
|
224
|
-
* @throws ServerAuthError if token is invalid
|
|
225
|
-
*/
|
|
226
|
-
async verifyToken(token: string): Promise<TokenPayload> {
|
|
227
|
-
// Check cache first
|
|
228
|
-
const cached = this.tokenCache.get(token);
|
|
229
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
230
|
-
return cached.payload;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
// Verify with remote endpoint
|
|
235
|
-
const response = await fetch(`${this.config.baseUrl}/api/v1/auth/verify`, {
|
|
236
|
-
method: 'POST',
|
|
237
|
-
headers: {
|
|
238
|
-
'Content-Type': 'application/json',
|
|
239
|
-
'X-App-Key': this.config.clientId,
|
|
240
|
-
'X-App-Secret': this.config.clientSecret,
|
|
241
|
-
},
|
|
242
|
-
body: JSON.stringify({ token }),
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
if (!response.ok) {
|
|
246
|
-
const errorResponse = await response.json() as ApiErrorResponse;
|
|
247
|
-
throw new ServerAuthError(
|
|
248
|
-
errorResponse.error?.code || ServerErrorCode.INVALID_TOKEN,
|
|
249
|
-
errorResponse.error?.message || 'Invalid token',
|
|
250
|
-
401
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const data = await response.json() as ApiSuccessResponse<TokenPayload>;
|
|
255
|
-
const payload = data.data;
|
|
256
|
-
|
|
257
|
-
// Cache the result (for 1 minute or until expiry, whichever is sooner)
|
|
258
|
-
const cacheExpiry = Math.min(payload.exp * 1000, Date.now() + 60 * 1000);
|
|
259
|
-
this.tokenCache.set(token, { payload, expiresAt: cacheExpiry });
|
|
260
|
-
|
|
261
|
-
return payload;
|
|
262
|
-
} catch (error) {
|
|
263
|
-
if (error instanceof ServerAuthError) {
|
|
264
|
-
throw error;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Try local verification as fallback
|
|
268
|
-
if (this.config.jwtPublicKey) {
|
|
269
|
-
return this.verifyTokenLocally(token);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
throw new ServerAuthError(
|
|
273
|
-
ServerErrorCode.VERIFICATION_FAILED,
|
|
274
|
-
error instanceof Error ? error.message : 'Token verification failed',
|
|
275
|
-
401
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Verify token locally using JWT public key
|
|
282
|
-
* 使用 JWT 公钥本地验证令牌
|
|
283
|
-
*/
|
|
284
|
-
private async verifyTokenLocally(token: string): Promise<TokenPayload> {
|
|
285
|
-
if (!this.config.jwtPublicKey) {
|
|
286
|
-
throw new ServerAuthError(ServerErrorCode.NO_PUBLIC_KEY, 'JWT public key not configured', 500);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
try {
|
|
290
|
-
const publicKey = await jose.importSPKI(this.config.jwtPublicKey, 'RS256');
|
|
291
|
-
const { payload } = await jose.jwtVerify(token, publicKey);
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
sub: payload.sub as string,
|
|
295
|
-
iss: payload.iss as string,
|
|
296
|
-
aud: payload.aud as string | string[],
|
|
297
|
-
iat: payload.iat as number,
|
|
298
|
-
exp: payload.exp as number,
|
|
299
|
-
scope: payload.scope as string | undefined,
|
|
300
|
-
azp: payload.azp as string | undefined,
|
|
301
|
-
phone: payload.phone as string | undefined,
|
|
302
|
-
email: payload.email as string | undefined,
|
|
303
|
-
};
|
|
304
|
-
} catch (error) {
|
|
305
|
-
throw new ServerAuthError(
|
|
306
|
-
ServerErrorCode.INVALID_TOKEN,
|
|
307
|
-
error instanceof Error ? error.message : 'Invalid token',
|
|
308
|
-
401
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ============================================
|
|
314
|
-
// OAuth2 Token Introspection (RFC 7662)
|
|
315
|
-
// ============================================
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Introspect a token (RFC 7662)
|
|
319
|
-
* 内省令牌(RFC 7662 标准)
|
|
320
|
-
*
|
|
321
|
-
* This is the standard way for resource servers to validate tokens.
|
|
322
|
-
*
|
|
323
|
-
* @param token - The token to introspect
|
|
324
|
-
* @param tokenTypeHint - Optional hint about the token type ('access_token' or 'refresh_token')
|
|
325
|
-
* @returns Introspection result
|
|
326
|
-
*
|
|
327
|
-
* @example
|
|
328
|
-
* ```typescript
|
|
329
|
-
* const result = await auth.introspectToken(accessToken);
|
|
330
|
-
* if (result.active) {
|
|
331
|
-
* console.log('Token is valid, user:', result.sub);
|
|
332
|
-
* }
|
|
333
|
-
* ```
|
|
334
|
-
*/
|
|
335
|
-
async introspectToken(token: string, tokenTypeHint?: 'access_token' | 'refresh_token'): Promise<IntrospectionResult> {
|
|
336
|
-
try {
|
|
337
|
-
// Use Basic Auth for client authentication
|
|
338
|
-
const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
|
|
339
|
-
|
|
340
|
-
const body: Record<string, string> = { token };
|
|
341
|
-
if (tokenTypeHint) {
|
|
342
|
-
body.token_type_hint = tokenTypeHint;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const response = await fetch(`${this.config.baseUrl}/api/v1/oauth2/introspect`, {
|
|
346
|
-
method: 'POST',
|
|
347
|
-
headers: {
|
|
348
|
-
'Content-Type': 'application/json',
|
|
349
|
-
'Authorization': `Basic ${credentials}`,
|
|
350
|
-
},
|
|
351
|
-
body: JSON.stringify(body),
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
const result = await response.json() as IntrospectionResult;
|
|
355
|
-
return result;
|
|
356
|
-
} catch (error) {
|
|
357
|
-
// On network error, return inactive
|
|
358
|
-
return { active: false };
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Check if a token is active
|
|
364
|
-
* 检查令牌是否有效
|
|
365
|
-
*
|
|
366
|
-
* @param token - The token to check
|
|
367
|
-
* @returns true if token is active
|
|
368
|
-
*/
|
|
369
|
-
async isTokenActive(token: string): Promise<boolean> {
|
|
370
|
-
const result = await this.introspectToken(token);
|
|
371
|
-
return result.active;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// ============================================
|
|
375
|
-
// User Management
|
|
376
|
-
// ============================================
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Get user info by ID
|
|
380
|
-
* 根据 ID 获取用户信息
|
|
381
|
-
*/
|
|
382
|
-
async getUser(userId: string): Promise<UserInfo> {
|
|
383
|
-
const response = await fetch(`${this.config.baseUrl}/api/v1/admin/users/${userId}`, {
|
|
384
|
-
method: 'GET',
|
|
385
|
-
headers: {
|
|
386
|
-
'Content-Type': 'application/json',
|
|
387
|
-
'X-App-Key': this.config.clientId,
|
|
388
|
-
'X-App-Secret': this.config.clientSecret,
|
|
389
|
-
},
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
if (!response.ok) {
|
|
393
|
-
const errorResponse = await response.json() as ApiErrorResponse;
|
|
394
|
-
throw new ServerAuthError(
|
|
395
|
-
errorResponse.error?.code || ServerErrorCode.USER_NOT_FOUND,
|
|
396
|
-
errorResponse.error?.message || 'User not found',
|
|
397
|
-
response.status
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const data = await response.json() as ApiSuccessResponse<UserInfo>;
|
|
402
|
-
return data.data;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// ============================================
|
|
406
|
-
// Express/Connect Middleware
|
|
407
|
-
// ============================================
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Express/Connect middleware for authentication
|
|
411
|
-
* Express/Connect 认证中间件
|
|
412
|
-
*
|
|
413
|
-
* @example
|
|
414
|
-
* ```typescript
|
|
415
|
-
* import express from 'express';
|
|
416
|
-
*
|
|
417
|
-
* const app = express();
|
|
418
|
-
* app.use('/api/*', auth.middleware());
|
|
419
|
-
*
|
|
420
|
-
* app.get('/api/profile', (req, res) => {
|
|
421
|
-
* res.json({ user: req.user });
|
|
422
|
-
* });
|
|
423
|
-
* ```
|
|
424
|
-
*/
|
|
425
|
-
middleware() {
|
|
426
|
-
return async (
|
|
427
|
-
req: ExpressRequest,
|
|
428
|
-
res: ExpressResponse,
|
|
429
|
-
next: NextFunction
|
|
430
|
-
): Promise<void> => {
|
|
431
|
-
const authHeader = req.headers['authorization'];
|
|
432
|
-
|
|
433
|
-
if (!authHeader || typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
|
|
434
|
-
res.status(401).json({
|
|
435
|
-
success: false,
|
|
436
|
-
error: {
|
|
437
|
-
code: ServerErrorCode.UNAUTHORIZED,
|
|
438
|
-
message: 'Authorization header is required / 需要授权头',
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
const token = authHeader.substring(7);
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
const payload = await this.verifyToken(token);
|
|
448
|
-
|
|
449
|
-
// Attach payload to request
|
|
450
|
-
req.authPayload = payload;
|
|
451
|
-
|
|
452
|
-
// Optionally fetch full user info
|
|
453
|
-
try {
|
|
454
|
-
req.user = await this.getUser(payload.sub);
|
|
455
|
-
} catch {
|
|
456
|
-
// User info not required for middleware to pass
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
next();
|
|
460
|
-
} catch (error) {
|
|
461
|
-
const authError = error instanceof ServerAuthError ? error : new ServerAuthError(
|
|
462
|
-
ServerErrorCode.UNAUTHORIZED,
|
|
463
|
-
'Authentication failed',
|
|
464
|
-
401
|
|
465
|
-
);
|
|
466
|
-
res.status(authError.statusCode).json({
|
|
467
|
-
success: false,
|
|
468
|
-
error: {
|
|
469
|
-
code: authError.code,
|
|
470
|
-
message: authError.message,
|
|
471
|
-
},
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// ============================================
|
|
478
|
-
// Hono Middleware
|
|
479
|
-
// ============================================
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Hono middleware for authentication
|
|
483
|
-
* Hono 认证中间件
|
|
484
|
-
*
|
|
485
|
-
* @example
|
|
486
|
-
* ```typescript
|
|
487
|
-
* import { Hono } from 'hono';
|
|
488
|
-
*
|
|
489
|
-
* const app = new Hono();
|
|
490
|
-
* app.use('/api/*', auth.honoMiddleware());
|
|
491
|
-
*
|
|
492
|
-
* app.get('/api/profile', (c) => {
|
|
493
|
-
* const user = c.get('user');
|
|
494
|
-
* return c.json({ user });
|
|
495
|
-
* });
|
|
496
|
-
* ```
|
|
497
|
-
*/
|
|
498
|
-
honoMiddleware(): HonoMiddlewareHandler {
|
|
499
|
-
return async (c: HonoContext, next: () => Promise<void>): Promise<Response | void> => {
|
|
500
|
-
const authHeader = c.req.header('authorization') || c.req.header('Authorization');
|
|
501
|
-
|
|
502
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
503
|
-
return c.json({
|
|
504
|
-
success: false,
|
|
505
|
-
error: {
|
|
506
|
-
code: ServerErrorCode.UNAUTHORIZED,
|
|
507
|
-
message: 'Authorization header is required / 需要授权头',
|
|
508
|
-
},
|
|
509
|
-
}, 401);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
const token = authHeader.substring(7);
|
|
513
|
-
|
|
514
|
-
try {
|
|
515
|
-
const payload = await this.verifyToken(token);
|
|
516
|
-
|
|
517
|
-
// Attach to context
|
|
518
|
-
c.set('authPayload', payload);
|
|
519
|
-
|
|
520
|
-
// Optionally fetch full user info
|
|
521
|
-
try {
|
|
522
|
-
const user = await this.getUser(payload.sub);
|
|
523
|
-
c.set('user', user);
|
|
524
|
-
} catch {
|
|
525
|
-
// User info not required for middleware to pass
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
await next();
|
|
529
|
-
} catch (error) {
|
|
530
|
-
const authError = error instanceof ServerAuthError ? error : new ServerAuthError(
|
|
531
|
-
ServerErrorCode.UNAUTHORIZED,
|
|
532
|
-
'Authentication failed',
|
|
533
|
-
401
|
|
534
|
-
);
|
|
535
|
-
return c.json({
|
|
536
|
-
success: false,
|
|
537
|
-
error: {
|
|
538
|
-
code: authError.code,
|
|
539
|
-
message: authError.message,
|
|
540
|
-
},
|
|
541
|
-
}, authError.statusCode);
|
|
542
|
-
}
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ============================================
|
|
547
|
-
// Utility Methods
|
|
548
|
-
// ============================================
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Clear token cache
|
|
552
|
-
* 清除令牌缓存
|
|
553
|
-
*/
|
|
554
|
-
clearCache(): void {
|
|
555
|
-
this.tokenCache.clear();
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Get cache statistics
|
|
560
|
-
* 获取缓存统计
|
|
561
|
-
*/
|
|
562
|
-
getCacheStats(): { size: number; entries: number } {
|
|
563
|
-
return {
|
|
564
|
-
size: this.tokenCache.size,
|
|
565
|
-
entries: this.tokenCache.size,
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// ============================================
|
|
571
|
-
// Legacy Compatibility
|
|
572
|
-
// ============================================
|
|
573
|
-
|
|
574
|
-
/** @deprecated Use ServerAuthError instead */
|
|
575
|
-
export interface AuthError extends Error {
|
|
576
|
-
code: string;
|
|
577
|
-
statusCode: number;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Default export
|
|
581
|
-
export default UniAuthServer;
|
package/src/server.test.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* UniAuth Server SDK Tests
|
|
3
|
-
* UniAuth 服务端 SDK 测试
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
7
|
-
import { UniAuthServer } from './index.js';
|
|
8
|
-
|
|
9
|
-
describe('UniAuthServer', () => {
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
vi.unstubAllGlobals();
|
|
16
|
-
vi.resetAllMocks();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe('Configuration', () => {
|
|
20
|
-
it('should create server with required config', () => {
|
|
21
|
-
const server = new UniAuthServer({
|
|
22
|
-
baseUrl: 'https://auth.example.com',
|
|
23
|
-
appKey: 'test-app-key',
|
|
24
|
-
appSecret: 'test-app-secret',
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
expect(server).toBeDefined();
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe('verifyToken', () => {
|
|
32
|
-
it('should verify token successfully', async () => {
|
|
33
|
-
const mockPayload = {
|
|
34
|
-
sub: 'user-123',
|
|
35
|
-
phone: '+8613800138000',
|
|
36
|
-
iat: Math.floor(Date.now() / 1000),
|
|
37
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
41
|
-
ok: true,
|
|
42
|
-
json: async () => ({ success: true, data: mockPayload }),
|
|
43
|
-
} as Response);
|
|
44
|
-
|
|
45
|
-
const server = new UniAuthServer({
|
|
46
|
-
baseUrl: 'https://auth.example.com',
|
|
47
|
-
appKey: 'test-key',
|
|
48
|
-
appSecret: 'test-secret',
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const result = await server.verifyToken('test-token');
|
|
52
|
-
|
|
53
|
-
expect(result.sub).toBe('user-123');
|
|
54
|
-
expect(result.phone).toBe('+8613800138000');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should throw error for invalid token', async () => {
|
|
58
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
59
|
-
ok: false,
|
|
60
|
-
json: async () => ({
|
|
61
|
-
success: false,
|
|
62
|
-
error: { code: 'INVALID_TOKEN', message: 'Token is invalid' },
|
|
63
|
-
}),
|
|
64
|
-
} as Response);
|
|
65
|
-
|
|
66
|
-
const server = new UniAuthServer({
|
|
67
|
-
baseUrl: 'https://auth.example.com',
|
|
68
|
-
appKey: 'test-key',
|
|
69
|
-
appSecret: 'test-secret',
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
await expect(server.verifyToken('invalid-token')).rejects.toThrow('Token is invalid');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('should cache verified tokens', async () => {
|
|
76
|
-
const mockPayload = {
|
|
77
|
-
sub: 'user-123',
|
|
78
|
-
phone: '+8613800138000',
|
|
79
|
-
iat: Math.floor(Date.now() / 1000),
|
|
80
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
84
|
-
ok: true,
|
|
85
|
-
json: async () => ({ success: true, data: mockPayload }),
|
|
86
|
-
} as Response);
|
|
87
|
-
|
|
88
|
-
const server = new UniAuthServer({
|
|
89
|
-
baseUrl: 'https://auth.example.com',
|
|
90
|
-
appKey: 'test-key',
|
|
91
|
-
appSecret: 'test-secret',
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// First call - should make HTTP request
|
|
95
|
-
await server.verifyToken('cached-token');
|
|
96
|
-
|
|
97
|
-
// Second call - should use cache
|
|
98
|
-
await server.verifyToken('cached-token');
|
|
99
|
-
|
|
100
|
-
// fetch should only be called once due to caching
|
|
101
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe('getUser', () => {
|
|
106
|
-
it('should get user info successfully', async () => {
|
|
107
|
-
const mockUser = {
|
|
108
|
-
id: 'user-123',
|
|
109
|
-
phone: '+8613800138000',
|
|
110
|
-
nickname: 'Test User',
|
|
111
|
-
avatar_url: null,
|
|
112
|
-
phone_verified: true,
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
116
|
-
ok: true,
|
|
117
|
-
json: async () => ({ success: true, data: mockUser }),
|
|
118
|
-
} as Response);
|
|
119
|
-
|
|
120
|
-
const server = new UniAuthServer({
|
|
121
|
-
baseUrl: 'https://auth.example.com',
|
|
122
|
-
appKey: 'test-key',
|
|
123
|
-
appSecret: 'test-secret',
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const result = await server.getUser('user-123');
|
|
127
|
-
|
|
128
|
-
expect(result.id).toBe('user-123');
|
|
129
|
-
expect(result.nickname).toBe('Test User');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('should throw error when user not found', async () => {
|
|
133
|
-
vi.mocked(fetch).mockResolvedValueOnce({
|
|
134
|
-
ok: false,
|
|
135
|
-
status: 404,
|
|
136
|
-
json: async () => ({
|
|
137
|
-
success: false,
|
|
138
|
-
error: { code: 'USER_NOT_FOUND', message: 'User not found' },
|
|
139
|
-
}),
|
|
140
|
-
} as Response);
|
|
141
|
-
|
|
142
|
-
const server = new UniAuthServer({
|
|
143
|
-
baseUrl: 'https://auth.example.com',
|
|
144
|
-
appKey: 'test-key',
|
|
145
|
-
appSecret: 'test-secret',
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
await expect(server.getUser('unknown-user')).rejects.toThrow('User not found');
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('clearCache', () => {
|
|
153
|
-
it('should clear the token cache', async () => {
|
|
154
|
-
const mockPayload = {
|
|
155
|
-
sub: 'user-123',
|
|
156
|
-
phone: '+8613800138000',
|
|
157
|
-
iat: Math.floor(Date.now() / 1000),
|
|
158
|
-
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
vi.mocked(fetch).mockResolvedValue({
|
|
162
|
-
ok: true,
|
|
163
|
-
json: async () => ({ success: true, data: mockPayload }),
|
|
164
|
-
} as Response);
|
|
165
|
-
|
|
166
|
-
const server = new UniAuthServer({
|
|
167
|
-
baseUrl: 'https://auth.example.com',
|
|
168
|
-
appKey: 'test-key',
|
|
169
|
-
appSecret: 'test-secret',
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// First call
|
|
173
|
-
await server.verifyToken('test-token');
|
|
174
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
175
|
-
|
|
176
|
-
// Clear cache
|
|
177
|
-
server.clearCache();
|
|
178
|
-
|
|
179
|
-
// Second call - should make new HTTP request
|
|
180
|
-
await server.verifyToken('test-token');
|
|
181
|
-
expect(fetch).toHaveBeenCalledTimes(2);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
describe('middleware', () => {
|
|
186
|
-
it('should create middleware function', () => {
|
|
187
|
-
const server = new UniAuthServer({
|
|
188
|
-
baseUrl: 'https://auth.example.com',
|
|
189
|
-
appKey: 'test-key',
|
|
190
|
-
appSecret: 'test-secret',
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const middleware = server.middleware();
|
|
194
|
-
|
|
195
|
-
expect(typeof middleware).toBe('function');
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('should reject requests without authorization header', async () => {
|
|
199
|
-
const server = new UniAuthServer({
|
|
200
|
-
baseUrl: 'https://auth.example.com',
|
|
201
|
-
appKey: 'test-key',
|
|
202
|
-
appSecret: 'test-secret',
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
const middleware = server.middleware();
|
|
206
|
-
|
|
207
|
-
const mockReq = {
|
|
208
|
-
headers: {},
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const mockRes = {
|
|
212
|
-
status: vi.fn().mockReturnThis(),
|
|
213
|
-
json: vi.fn(),
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
const mockNext = vi.fn();
|
|
217
|
-
|
|
218
|
-
await middleware(mockReq as any, mockRes as any, mockNext);
|
|
219
|
-
|
|
220
|
-
expect(mockRes.status).toHaveBeenCalledWith(401);
|
|
221
|
-
expect(mockRes.json).toHaveBeenCalledWith({
|
|
222
|
-
success: false,
|
|
223
|
-
error: {
|
|
224
|
-
code: 'UNAUTHORIZED',
|
|
225
|
-
message: expect.stringContaining('Authorization header'),
|
|
226
|
-
},
|
|
227
|
-
});
|
|
228
|
-
expect(mockNext).not.toHaveBeenCalled();
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
});
|