sumor 3.2.3 → 3.3.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.
Files changed (64) hide show
  1. package/README.md +372 -413
  2. package/README.zh-CN.md +576 -0
  3. package/dist/server/index.d.ts +2 -6
  4. package/dist/server/index.d.ts.map +1 -1
  5. package/dist/server/index.js +1 -5
  6. package/dist/server/index.js.map +1 -1
  7. package/dist/server/middlewares/loadJwtUserMiddleware.d.ts +0 -1
  8. package/dist/server/middlewares/loadJwtUserMiddleware.d.ts.map +1 -1
  9. package/dist/server/middlewares/loadJwtUserMiddleware.js +4 -2
  10. package/dist/server/middlewares/loadJwtUserMiddleware.js.map +1 -1
  11. package/dist/server/mock/mockApiRoutes.d.ts +14 -0
  12. package/dist/server/mock/mockApiRoutes.d.ts.map +1 -0
  13. package/dist/server/mock/mockApiRoutes.js +151 -0
  14. package/dist/server/mock/mockApiRoutes.js.map +1 -0
  15. package/dist/server/mock/mockConfig.d.ts +38 -0
  16. package/dist/server/mock/mockConfig.d.ts.map +1 -0
  17. package/dist/server/mock/mockConfig.js +51 -0
  18. package/dist/server/mock/mockConfig.js.map +1 -0
  19. package/dist/server/mock/mockRoutes.d.ts +9 -0
  20. package/dist/server/mock/mockRoutes.d.ts.map +1 -0
  21. package/dist/server/mock/mockRoutes.js +103 -0
  22. package/dist/server/mock/mockRoutes.js.map +1 -0
  23. package/dist/server/mock/mockTokenUtils.d.ts +30 -0
  24. package/dist/server/mock/mockTokenUtils.d.ts.map +1 -0
  25. package/dist/server/mock/mockTokenUtils.js +81 -0
  26. package/dist/server/mock/mockTokenUtils.js.map +1 -0
  27. package/dist/server/routes.d.ts +1 -0
  28. package/dist/server/routes.d.ts.map +1 -1
  29. package/dist/server/routes.js +29 -25
  30. package/dist/server/routes.js.map +1 -1
  31. package/dist/server/services/oauthService.d.ts +0 -8
  32. package/dist/server/services/oauthService.d.ts.map +1 -1
  33. package/dist/server/services/oauthService.js +0 -24
  34. package/dist/server/services/oauthService.js.map +1 -1
  35. package/dist/server/types/oauth.d.ts +0 -1
  36. package/dist/server/types/oauth.d.ts.map +1 -1
  37. package/dist/server/utils/config.d.ts.map +1 -1
  38. package/dist/server/utils/config.js +13 -0
  39. package/dist/server/utils/config.js.map +1 -1
  40. package/dist/web/OAuthStore.d.ts +11 -5
  41. package/dist/web/OAuthStore.d.ts.map +1 -1
  42. package/dist/web/OAuthStore.js +43 -64
  43. package/dist/web/OAuthStore.js.map +1 -1
  44. package/dist/web/UrlHelper.d.ts +1 -0
  45. package/dist/web/UrlHelper.d.ts.map +1 -1
  46. package/dist/web/UrlHelper.js +11 -0
  47. package/dist/web/UrlHelper.js.map +1 -1
  48. package/dist/web/api/login.d.ts +2 -2
  49. package/dist/web/api/login.d.ts.map +1 -1
  50. package/dist/web/api/login.js +12 -2
  51. package/dist/web/api/login.js.map +1 -1
  52. package/dist/web/api/logout.d.ts +1 -1
  53. package/dist/web/api/logout.d.ts.map +1 -1
  54. package/dist/web/api/logout.js +3 -2
  55. package/dist/web/api/logout.js.map +1 -1
  56. package/package.json +2 -1
  57. package/dist/server/middlewares/isLoggedMiddleware.d.ts +0 -15
  58. package/dist/server/middlewares/isLoggedMiddleware.d.ts.map +0 -1
  59. package/dist/server/middlewares/isLoggedMiddleware.js +0 -35
  60. package/dist/server/middlewares/isLoggedMiddleware.js.map +0 -1
  61. package/dist/server/middlewares/isVerifiedMiddleware.d.ts +0 -16
  62. package/dist/server/middlewares/isVerifiedMiddleware.d.ts.map +0 -1
  63. package/dist/server/middlewares/isVerifiedMiddleware.js +0 -44
  64. package/dist/server/middlewares/isVerifiedMiddleware.js.map +0 -1
package/README.md CHANGED
@@ -5,614 +5,573 @@
5
5
 
6
6
  A comprehensive OAuth 2.0 authentication framework for Express.js applications with role-based access control (RBAC). Sumor simplifies OAuth integration, token management, and permission-based route protection in multi-service architectures.
7
7
 
8
- ## ✨ Key Features
8
+ [中文文档](README.zh-CN.md)
9
9
 
10
- - **🔐 OAuth 2.0 Complete Flow**: Full authorization code flow with PKCE support
11
- - **🔑 Session & Token Management**: Secure token exchange, refresh, and blacklisting
12
- - **🛡️ JWT Verification**: Built-in JWT validation using JWKS (JSON Web Key Set)
13
- - **👥 Role-Based Access Control (RBAC)**: Permission-based route protection and middleware
14
- - **📝 TypeScript First**: Full TypeScript support with complete type definitions
15
- - **🚀 Express Integration**: Drop-in middleware and route setup
16
- - **🎯 Permission Sync**: Automatic permission synchronization with OAuth provider
17
- - **💾 Session Revocation**: Token blacklist support for logout and session management
18
- - **🌐 Multi-Domain Support**: Built-in domain and origin handling
19
- - **⚡ Request Context**: Access user info and OAuth service in Express request object
10
+ ## Key Features
20
11
 
21
- ## 📦 Installation
12
+ - **OAuth 2.0 Complete Flow**: Full authorization code flow with automatic token exchange
13
+ - **Session & Token Management**: Secure token exchange, refresh via HTTP-only cookies
14
+ - **JWT Verification**: Built-in JWT validation using JWKS (JSON Web Key Set)
15
+ - **Role-Based Access Control (RBAC)**: Permission sync and runtime permission/role checks
16
+ - **TypeScript First**: Full TypeScript support with complete type definitions
17
+ - **Express Integration**: Drop-in middleware and pre-configured route setup
18
+ - **Permission Sync**: Register application permissions to the OAuth provider at startup
19
+ - **Mock Mode**: Zero-dependency local development without a real OAuth server
20
+ - **Web Client SDK**: Browser-side token refresh, user state, and permission utilities
21
+
22
+ ## Installation
22
23
 
23
24
  ```bash
24
25
  npm install sumor
25
26
  ```
26
27
 
27
- ## 🚀 Quick Start
28
+ ## Architecture Overview
28
29
 
29
- ### Server Setup
30
+ Sumor is split into two entry points:
30
31
 
31
- ```typescript
32
- import { OAuthService, loadJwtUserMiddleware, oauthRoutes } from 'sumor'
32
+ | Entry | Purpose |
33
+ | ----------- | ------------------------------- |
34
+ | `sumor` | Server-side (Node.js / Express) |
35
+ | `sumor/web` | Client-side (browser) |
33
36
 
34
- // Define permissions for your application
35
- const permissionConfig = {
36
- permissions: ['posts:view', 'posts:edit', 'posts:delete'],
37
- permissionLabels: [
38
- {
39
- module: 'posts',
40
- zh: '帖子管理',
41
- en: 'Posts Management'
42
- }
43
- ]
44
- }
45
-
46
- // Initialize OAuth
47
- const oauthService = new OAuthService()
48
- await oauthService.updatePermissions(permissionConfig)
49
-
50
- // Register JWT middleware and OAuth routes
51
- app.use('/api', loadJwtUserMiddleware)
52
- app.use('/api/oauth', oauthRoutes)
37
+ ```
38
+ Browser App
39
+ │ (1) refreshToken() on init
40
+ │ (2) login() → redirect to OAuth provider
41
+ │ (3) OAuth callback → /api/oauth/callback
42
+ │ (4) Subsequent requests carry HttpOnly cookie
43
+
44
+ Your Express App
45
+ ├── oauthRoutes ← /api/oauth/* (token refresh, callback, logout)
46
+ ├── loadJwtUserMiddleware ← validates JWT, injects req.jwtUser
47
+ └── Your routes ← access req.jwtUser, call OAuthService methods
48
+
49
+ OAuth Provider
50
+ └── issues JWT, manages users & permissions, exposes JWKS
53
51
  ```
54
52
 
55
- ### Accessing User Info in Routes
53
+ ---
56
54
 
57
- ```typescript
58
- // User info from JWT token is available in req.jwtUser
59
- app.get('/api/user/profile', (req: any, res) => {
60
- const user = req.jwtUser
55
+ ## Server-Side Usage
61
56
 
62
- res.json({
63
- userId: user.userId,
64
- roles: user.roles?.split(','),
65
- permissions: user.permissions?.split(','),
66
- verified: user.isVerified
67
- })
68
- })
57
+ ### Install
69
58
 
70
- // Call OAuth service methods via req.sumor
71
- app.get('/api/users/:userId', async (req: any, res) => {
72
- try {
73
- // Fetch user info from OAuth provider
74
- const userInfo = await req.sumor.getUserInfo(req.params.userId)
75
- res.json(userInfo)
76
- } catch (error) {
77
- res.status(400).json({ error: error.message })
78
- }
79
- })
59
+ ```typescript
60
+ import { OAuthService, loadJwtUserMiddleware, oauthRoutes } from 'sumor'
80
61
  ```
81
62
 
82
- ## 📚 API Reference
63
+ ### Exports
83
64
 
84
- ### OAuthService Class
65
+ | Export | Type | Description |
66
+ | ----------------------- | -------------- | ------------------------------------- |
67
+ | `OAuthService` | Class | Interact with the OAuth provider API |
68
+ | `loadJwtUserMiddleware` | Middleware | Validate JWT and inject `req.jwtUser` |
69
+ | `oauthRoutes` | Express Router | Pre-configured OAuth routes |
85
70
 
86
- #### updatePermissions(config)
71
+ ### Setup
87
72
 
88
- Synchronize permissions to the OAuth provider.
73
+ Register the OAuth routes and middleware in your Express app:
89
74
 
90
- **Parameters:**
75
+ ```typescript
76
+ import express from 'express'
77
+ import { OAuthService, loadJwtUserMiddleware, oauthRoutes } from 'sumor'
91
78
 
92
- - `config` (Object):
93
- - `permissions` (string[]) - List of permission strings in format `<module>:<operation>`
94
- - `permissionLabels` (Array) - Labels with `module`, `zh`, `en` fields
79
+ const app = express()
95
80
 
96
- **Example:**
81
+ // Register pre-configured OAuth routes (callback, token refresh, logout)
82
+ app.use('/api/oauth', oauthRoutes)
97
83
 
98
- ```typescript
99
- const oauthService = new OAuthService()
100
- await oauthService.updatePermissions({
101
- permissions: ['posts:view', 'posts:edit', 'posts:delete'],
102
- permissionLabels: [
103
- {
104
- module: 'posts',
105
- zh: '帖子管理',
106
- en: 'Posts Management'
107
- }
108
- ]
109
- })
84
+ // JWT middleware: validates token and injects req.jwtUser for all routes below
85
+ app.use(loadJwtUserMiddleware)
86
+
87
+ // Your protected routes go here
88
+ app.use('/api/user', userRoutes)
110
89
  ```
111
90
 
112
- ### Middleware & Routes
91
+ > **Note:** Register `oauthRoutes` before `loadJwtUserMiddleware` so that OAuth callback endpoints (`/api/oauth/callback`) are publicly accessible.
113
92
 
114
- #### loadJwtUserMiddleware
93
+ ### Sync Permissions on Startup
115
94
 
116
- Middleware that validates JWT tokens and extracts user information into `req.jwtUser`.
95
+ Register your application's permission definitions with the OAuth provider once on startup:
117
96
 
118
97
  ```typescript
119
- app.use('/api', loadJwtUserMiddleware)
98
+ const oauthService = new OAuthService()
99
+
100
+ await oauthService.updatePermissions({
101
+ permissions: ['posts:view', 'posts:create', 'posts:edit', 'posts:delete'],
102
+ permissionLabels: [{ module: 'posts', zh: '文章管理', en: 'Posts Management' }]
103
+ })
120
104
  ```
121
105
 
122
- #### oauthRoutes
106
+ Permission strings follow the format `<module>:<operation>`.
123
107
 
124
- Pre-configured OAuth routes:
108
+ ### Access User Info in Routes
125
109
 
126
- - `GET /api/oauth/callback` - Handle OAuth callback (no auth required)
127
- - `PUT /api/oauth/token` - Refresh token and get user info + authorization URL
128
- - `POST /api/oauth/logout` - Logout and revoke session
110
+ After `loadJwtUserMiddleware`, `req.jwtUser` contains the decoded JWT payload:
129
111
 
130
112
  ```typescript
131
- app.use('/api/oauth', oauthRoutes)
132
- ```
113
+ app.get('/api/profile', (req, res) => {
114
+ const { userId, roles, permissions, isVerified } = req.jwtUser
133
115
 
134
- ### req.jwtUser
116
+ res.json({
117
+ userId,
118
+ roles: roles?.split(',') ?? [],
119
+ permissions: permissions?.split(',') ?? [],
120
+ isVerified: isVerified === 1
121
+ })
122
+ })
123
+ ```
135
124
 
136
- Available in all route handlers after middleware initialization. Contains the user info from JWT token.
125
+ **`req.jwtUser` properties:**
137
126
 
138
- **Properties:**
127
+ | Property | Type | Description |
128
+ | ------------- | -------- | ---------------------------------- |
129
+ | `userId` | `string` | Unique user identifier |
130
+ | `roles` | `string` | Comma-separated role IDs |
131
+ | `permissions` | `string` | Comma-separated permission strings |
132
+ | `isVerified` | `number` | `1` if verified, `0` otherwise |
133
+ | `tenantId` | `string` | Multi-tenant identifier |
134
+ | `jti` | `string` | JWT ID (session identifier) |
135
+ | `exp` | `number` | Token expiration timestamp |
136
+ | `iat` | `number` | Token issued-at timestamp |
139
137
 
140
- - `userId` (string) - Unique user identifier
141
- - `roles` (string) - Comma-separated role IDs
142
- - `permissions` (string) - Comma-separated user permissions
143
- - `isVerified` (number) - Verification status
144
- - `tenantId` (string) - Multi-tenant identifier
145
- - `exp` (number) - Token expiration timestamp
146
- - `iat` (number) - Token issued at timestamp
138
+ ### OAuthService Methods
147
139
 
148
- **Example:**
140
+ Create an instance anywhere in your server code — it reads configuration from environment variables automatically:
149
141
 
150
142
  ```typescript
151
- app.get('/api/protected', (req: any, res) => {
152
- const { userId, roles, permissions } = req.jwtUser
153
- res.json({ userId, roles: roles?.split(','), permissions: permissions?.split(',') })
154
- })
143
+ const oauthService = new OAuthService()
155
144
  ```
156
145
 
157
- ### req.sumor (OAuthService)
158
-
159
- Available in all route handlers. Provides methods to call the OAuth provider's API.
160
-
161
- **Methods:**
162
-
163
- #### getUserInfo(userId)
146
+ #### `updatePermissions(config)`
164
147
 
165
- Get detailed user information from the OAuth provider.
148
+ Synchronize the application's permission definitions to the OAuth provider.
166
149
 
167
150
  ```typescript
168
- const userInfo = await req.sumor.getUserInfo('user123')
169
- // Returns: { userId, name, email, avatar, ... }
151
+ await oauthService.updatePermissions({
152
+ permissions: ['resource:view', 'resource:edit'],
153
+ permissionLabels: [{ module: 'resource', zh: '资源管理', en: 'Resource Management' }]
154
+ })
170
155
  ```
171
156
 
172
- #### getUsersInfo(userIds)
157
+ #### `getUserInfo(userId)`
173
158
 
174
- Get information for multiple users.
159
+ Fetch detailed information for a single user.
175
160
 
176
161
  ```typescript
177
- const users = await req.sumor.getUsersInfo(['user1', 'user2'])
178
- // Returns: [{ userId, name, ... }, ...]
162
+ const userInfo = await oauthService.getUserInfo('user-123')
163
+ // Returns: { userId, username, nickname, email, avatar, ... }
179
164
  ```
180
165
 
181
- #### searchUsers(searchTerm, limit)
166
+ #### `getUsersInfo(userIds)`
182
167
 
183
- Search for users by name or email.
168
+ Fetch information for multiple users in one call.
184
169
 
185
170
  ```typescript
186
- const results = await req.sumor.searchUsers('john', 20)
187
- // Returns: [{ userId, name, email, ... }, ...]
171
+ const users = await oauthService.getUsersInfo(['user-1', 'user-2', 'user-3'])
172
+ // Returns: [{ userId, username, ... }, ...]
188
173
  ```
189
174
 
190
- #### exchangeCode(grantType, code, redirectUri, codeVerifier)
175
+ #### `searchUsers(searchTerm, limit)`
191
176
 
192
- Exchange authorization code for tokens (internal use).
177
+ Search users by name or email.
193
178
 
194
179
  ```typescript
195
- const tokens = await req.sumor.exchangeCode(
196
- 'authorization_code',
197
- authCode,
198
- 'http://localhost:3000/callback',
199
- codeVerifier
200
- )
201
- // Returns: { accessToken, refreshToken, expiresIn, tokenType }
180
+ const results = await oauthService.searchUsers('alice', 20)
181
+ // Returns: [{ userId, username, email, ... }, ...]
202
182
  ```
203
183
 
204
- #### checkBlacklist(sessionId)
184
+ #### `revokeSession(sessionId)`
205
185
 
206
- Check if a session token is revoked.
186
+ Revoke (blacklist) a session on logout.
207
187
 
208
188
  ```typescript
209
- const isBlacklisted = await req.sumor.checkBlacklist(sessionId)
189
+ await oauthService.revokeSession(req.jwtUser.jti)
210
190
  ```
211
191
 
212
- #### revokeSession(sessionId)
192
+ #### `checkBlacklist(sessionId)`
213
193
 
214
- Revoke (logout) a session.
194
+ Check whether a session has been revoked.
215
195
 
216
196
  ```typescript
217
- await req.sumor.revokeSession(sessionId)
197
+ const isRevoked = await oauthService.checkBlacklist(sessionId)
218
198
  ```
219
199
 
220
- ### OAuth Routes
200
+ ### Pre-configured OAuth Routes
221
201
 
222
- Sumor automatically registers these routes:
202
+ `oauthRoutes` registers the following endpoints automatically:
223
203
 
224
- - `GET /api/oauth/callback` - Handle OAuth provider callback with authorization code (no auth required)
225
- - `PUT /api/oauth/token` - Refresh access token and get user info + authorization URL (can use refreshToken from body or cookie)
226
- - Response includes `endpoint` and `authorizeUrl` for OAuth configuration, and `user` object with current user info
227
- - `POST /api/oauth/logout` - Logout and revoke session (requires valid token)
204
+ | Method | Path | Auth required | Description |
205
+ | ------ | --------------------- | ------------- | ------------------------------------------------------ |
206
+ | `GET` | `/api/oauth/callback` | No | Exchange authorization code for tokens |
207
+ | `PUT` | `/api/oauth/token` | No | Refresh access token; returns user info and OAuth URLs |
208
+ | `POST` | `/api/oauth/logout` | Yes | Revoke session and clear cookies |
228
209
 
229
- ## 🎯 Web Client (Sumor Class)
210
+ ---
230
211
 
231
- The Sumor framework includes a client-side class for browser applications to manage OAuth and user state.
212
+ ## Client-Side Usage (`sumor/web`)
232
213
 
233
- ### Basic Setup
214
+ ### Install
234
215
 
235
216
  ```typescript
236
- import { setupSumor } from 'sumor'
237
-
238
- // Call this on app initialization
239
- await setupSumor()
217
+ import {
218
+ refreshToken,
219
+ login,
220
+ logout,
221
+ hasPermission,
222
+ hasRole,
223
+ oauthUrl,
224
+ oauthStore,
225
+ axios
226
+ } from 'sumor/web'
227
+ import type { ApiResponse } from 'sumor/web'
228
+ ```
229
+
230
+ ### Exports
231
+
232
+ | Export | Type | Description |
233
+ | --------------- | -------------- | ----------------------------------------------------- |
234
+ | `refreshToken` | Function | Refresh token and sync user state (call on app init) |
235
+ | `login` | Function | Redirect to OAuth login page |
236
+ | `logout` | Function | Call logout endpoint and clear user state |
237
+ | `hasPermission` | Function | Check if the current user has a specific permission |
238
+ | `hasRole` | Function | Check if the current user has a specific role |
239
+ | `oauthStore` | Singleton | In-memory user and OAuth state store |
240
+ | `oauthUrl` | Object | Helpers to generate OAuth provider navigation URLs |
241
+ | `axios` | Axios instance | Pre-configured Axios with automatic 401 token refresh |
242
+ | `ApiResponse` | Type | Standard API response wrapper type |
243
+
244
+ ### Initialize on App Start
245
+
246
+ Call `refreshToken()` once during application initialization to restore user state from the stored refresh token cookie:
240
247
 
241
- // Now use window.sumor to access the Sumor client
242
- console.log(window.sumor.user) // Current user info or null
243
- ```
244
-
245
- ### Sumor Client Properties
248
+ ```typescript
249
+ // main.ts (or App.vue onMounted)
250
+ import { refreshToken } from 'sumor/web'
246
251
 
247
- - `endpoint` - OAuth provider endpoint
248
- - `authorizeUrl` - Authorization URL for login redirect
249
- - `user` - Current user object or null
250
- - `id` - User ID
251
- - `isVerified` - Verification status
252
- - `roles` - Comma-separated role list
253
- - `permissions` - Comma-separated permission list
252
+ await refreshToken()
254
253
 
255
- ### Sumor Client Methods
254
+ // User state is now available via oauthStore
255
+ ```
256
256
 
257
- #### refresh(force = false)
257
+ > In SSR environments, `refreshToken()` is a no-op and returns immediately.
258
258
 
259
- Refresh OAuth configuration and user info from `PUT /api/oauth/token` using the stored refresh token.
259
+ ### Login and Logout
260
260
 
261
261
  ```typescript
262
- // Use cache if available
263
- await window.sumor.refresh()
262
+ import { login, logout } from 'sumor/web'
264
263
 
265
- // Force refresh, ignore cache
266
- await window.sumor.refresh(true)
264
+ // Redirect to OAuth authorization page
265
+ login()
266
+
267
+ // Logout: revokes session and clears user state
268
+ await logout()
267
269
  ```
268
270
 
269
- #### login()
271
+ ### Subscribe to User Changes
270
272
 
271
- Redirect to OAuth authorization page.
273
+ Use `oauthStore` to read the current user and react to state changes:
272
274
 
273
275
  ```typescript
274
- window.sumor.login()
275
- ```
276
+ import { oauthStore } from 'sumor/web'
276
277
 
277
- #### logout()
278
+ // Read current user
279
+ const user = oauthStore.getUser()
280
+ // { id, isVerified, roles, permissions } or null
278
281
 
279
- Logout and clear local user state.
280
-
281
- ```typescript
282
- await window.sumor.logout()
283
- // window.sumor.user becomes null
282
+ // Subscribe to login/logout events
283
+ oauthStore.onUserChange(user => {
284
+ if (user) {
285
+ console.log('Logged in:', user.id)
286
+ } else {
287
+ console.log('Logged out')
288
+ }
289
+ })
284
290
  ```
285
291
 
286
- #### hasPermission(module, operation = '\*')
292
+ **`UserInfo` properties:**
287
293
 
288
- Check if user has a specific permission.
294
+ | Property | Type | Description |
295
+ | ------------- | -------- | ---------------------------------- |
296
+ | `id` | `string` | User ID |
297
+ | `isVerified` | `number` | `1` if verified |
298
+ | `roles` | `string` | Comma-separated role IDs |
299
+ | `permissions` | `string` | Comma-separated permission strings |
300
+
301
+ ### Permission and Role Checks
289
302
 
290
303
  ```typescript
291
- // Check specific permission
292
- if (window.sumor.hasPermission('posts', 'edit')) {
304
+ import { hasPermission, hasRole } from 'sumor/web'
305
+
306
+ // Check a specific permission (module + operation)
307
+ if (hasPermission('posts', 'edit')) {
293
308
  // User can edit posts
294
309
  }
295
310
 
296
- // Check module (any operation)
297
- if (window.sumor.hasPermission('posts')) {
311
+ // Check any permission in a module (wildcard)
312
+ if (hasPermission('posts', '*')) {
298
313
  // User has any posts permission
299
314
  }
300
315
 
301
- if (window.sumor.hasPermission('posts', '*')) {
302
- // Same as above
316
+ // Check role
317
+ if (hasRole('admin')) {
318
+ // User is an admin
303
319
  }
304
320
  ```
305
321
 
306
- #### hasRole(role)
307
-
308
- Check if user has a specific role.
322
+ ### OAuth URL Helpers
309
323
 
310
324
  ```typescript
311
- if (window.sumor.hasRole('admin')) {
312
- // User is admin
313
- }
325
+ import { oauthUrl } from 'sumor/web'
326
+
327
+ oauthUrl.avatar(userId) // URL for a user's avatar image
328
+ oauthUrl.user() // URL for the user profile page
329
+ oauthUrl.home() // URL for the OAuth provider home page
330
+ oauthUrl.site() // URL for the site management page
331
+ oauthUrl.feedback() // URL for the feedback page
314
332
  ```
315
333
 
316
- #### onUserChange(callback)
334
+ In [Mock Mode](#mock-mode), these URLs point to placeholder pages served locally.
335
+
336
+ ### Making Authenticated HTTP Requests
317
337
 
318
- Subscribe to user state changes (login, logout, token refresh).
338
+ The exported `axios` instance automatically handles 401 responses by refreshing the token and retrying:
319
339
 
320
340
  ```typescript
321
- window.sumor.onUserChange(user => {
322
- if (user) {
323
- console.log('User logged in:', user.id)
324
- } else {
325
- console.log('User logged out')
341
+ import { axios } from 'sumor/web'
342
+ import type { ApiResponse } from 'sumor/web'
343
+
344
+ // GET
345
+ const { data } = await axios.get<ApiResponse<UserInfo>>('/api/user/info')
346
+
347
+ // POST
348
+ const { data } = await axios.post<ApiResponse<Item>>('/api/items', { name: 'My Item' })
349
+
350
+ // PUT
351
+ const { data } = await axios.put<ApiResponse<Item>>(`/api/items/${id}`, updates)
352
+
353
+ // DELETE
354
+ const { data } = await axios.delete<ApiResponse<null>>(`/api/items/${id}`)
355
+
356
+ // File upload with progress
357
+ await axios.post<ApiResponse<UploadResult>>('/api/upload', formData, {
358
+ headers: { 'Content-Type': 'multipart/form-data' },
359
+ onUploadProgress: e => {
360
+ const percent = Math.round((e.loaded * 100) / (e.total ?? e.loaded))
361
+ console.log(`Upload progress: ${percent}%`)
326
362
  }
327
363
  })
328
364
  ```
329
365
 
330
- ### Web Client Example
366
+ **`ApiResponse<T>` type:**
331
367
 
332
368
  ```typescript
333
- import { setupSumor } from 'sumor'
334
- import { ref, watch } from 'vue'
335
-
336
- export default {
337
- setup() {
338
- const userInfo = ref(null)
339
- const isLoggedIn = ref(false)
340
-
341
- onMounted(async () => {
342
- await setupSumor()
343
-
344
- // Get initial user state
345
- userInfo.value = window.sumor.user
346
- isLoggedIn.value = !!window.sumor.user
347
-
348
- // Subscribe to user changes
349
- window.sumor.onUserChange(user => {
350
- userInfo.value = user
351
- isLoggedIn.value = !!user
352
- })
353
- })
354
-
355
- return {
356
- userInfo,
357
- isLoggedIn,
358
- login: () => window.sumor.login(),
359
- logout: () => window.sumor.logout(),
360
- canEdit: () => window.sumor.hasPermission('posts', 'edit')
361
- }
362
- }
369
+ interface ApiResponse<T> {
370
+ code: string // 'OK' on success
371
+ message: string
372
+ data: T
363
373
  }
364
374
  ```
365
375
 
366
- ## 📚 API Reference
376
+ ---
377
+
378
+ ## Configuration
367
379
 
368
- Sumor uses environment variables for configuration. The key is configuring the OAuth provider endpoint:
380
+ Sumor reads all configuration from environment variables. No explicit constructor arguments are required.
381
+
382
+ ### Required Environment Variables
369
383
 
370
384
  ```bash
371
- # OAuth Provider Configuration
372
385
  OAUTH_ENDPOINT=https://auth.example.com
373
386
  OAUTH_CLIENT_KEY=your-app-client-id
374
387
  OAUTH_CLIENT_SECRET=your-app-client-secret
375
388
  OAUTH_REDIRECT_URI=http://localhost:3000/api/oauth/callback
376
389
  ```
377
390
 
378
- **How it works:**
379
-
380
- - `OAUTH_ENDPOINT` - Base URL of your OAuth provider (e.g., `https://auth.example.com`)
381
- - OAuth endpoints are automatically derived: `{OAUTH_ENDPOINT}/api/oauth/...`
382
- - `OAUTH_CLIENT_KEY` and `OAUTH_CLIENT_SECRET` - OAuth application credentials
383
- - `OAUTH_REDIRECT_URI` - Callback URL that matches your OAuth provider configuration
384
- - JWT tokens are verified using JWKS public keys from the OAuth provider (no local secret needed)
385
-
386
- **Example configurations:**
387
-
388
- ```bash
389
- # Local development
390
- OAUTH_ENDPOINT=http://localhost:3001
391
- OAUTH_CLIENT_KEY=myapp-dev
392
- OAUTH_CLIENT_SECRET=myapp-dev-secret
393
- OAUTH_REDIRECT_URI=http://localhost:3000/api/oauth/callback
391
+ | Variable | Description |
392
+ | --------------------- | ------------------------------------------------------ |
393
+ | `OAUTH_ENDPOINT` | Base URL of the OAuth provider |
394
+ | `OAUTH_CLIENT_KEY` | OAuth application client ID |
395
+ | `OAUTH_CLIENT_SECRET` | OAuth application client secret |
396
+ | `OAUTH_REDIRECT_URI` | Callback URL (must match OAuth provider configuration) |
394
397
 
395
- # Production
396
- OAUTH_ENDPOINT=https://auth.mycompany.com
397
- OAUTH_CLIENT_KEY=myapp-prod
398
- OAUTH_CLIENT_SECRET=<secure-secret>
399
- OAUTH_REDIRECT_URI=https://app.mycompany.com/api/oauth/callback
400
- ```
398
+ JWT tokens are verified using JWKS public keys fetched from `{OAUTH_ENDPOINT}/api/oauth/jwks` — no local secret is required.
401
399
 
402
- ## 📚 Usage Examples
400
+ ---
403
401
 
404
- ### Example 1: Protect Routes with Permission Checks
402
+ ## Mock Mode
405
403
 
406
- ```typescript
407
- // Server-side: Express route with permission check
408
- app.post('/api/posts', (req: any, res) => {
409
- // Check if user has permission
410
- const permissions = req.jwtUser.permissions?.split(',') || []
404
+ Mock Mode allows local development without a running OAuth server. Enable it by setting `OAUTH_MOCK=true`. In this mode, Sumor issues locally signed JWT tokens using HS256 and exposes additional mock-only endpoints.
411
405
 
412
- if (!permissions.includes('posts:create')) {
413
- return res.status(403).json({ error: 'Insufficient permissions' })
414
- }
406
+ ### How It Works
415
407
 
416
- // Create post logic here
417
- res.json({ postId: 123 })
418
- })
408
+ When `OAUTH_MOCK=true`:
419
409
 
420
- // Client-side: Vue component with permission check
421
- export default {
422
- setup() {
423
- return {
424
- canCreatePost: () => window.sumor.hasPermission('posts', 'create')
425
- }
426
- }
427
- }
410
+ - `oauthRoutes` registers extra sub-routes under `/api/oauth/mock/`
411
+ - The `PUT /api/oauth/token` route validates mock tokens locally instead of calling the OAuth provider
412
+ - `login()` on the web client calls `POST /api/oauth/mock/login` directly instead of redirecting to the OAuth provider
413
+ - `logout()` calls `POST /api/oauth/mock/logout` to clear cookies locally
414
+ - `oauthUrl.*` helpers return local placeholder pages instead of external OAuth URLs
428
415
 
429
- // Template
430
- <button v-if="canCreatePost()" @click="createPost">Create Post</button>
431
- ```
416
+ ### Mock Server Environment Variables
432
417
 
433
- ### Example 2: Multi-Tenant Support
418
+ ```bash
419
+ # Enable mock mode
420
+ OAUTH_MOCK=true
434
421
 
435
- ```typescript
436
- app.get('/api/tenant/users', (req: any, res) => {
437
- const tenantId = req.jwtUser.tenantId
422
+ # Mock user configuration (all optional — defaults shown)
423
+ OAUTH_MOCK_USER_ID=mock-user-001
424
+ OAUTH_MOCK_USER_ROLES=admin
425
+ OAUTH_MOCK_USER_PERMISSIONS=
426
+ OAUTH_MOCK_USER_IS_VERIFIED=1
438
427
 
439
- // Fetch users for the user's tenant
440
- db.query('SELECT * FROM users WHERE tenant_id = ?', [tenantId]).then(users => res.json(users))
441
- })
428
+ # URLs returned by the mock token endpoint
429
+ OAUTH_MOCK_ENDPOINT=http://localhost
430
+ OAUTH_MOCK_REDIRECT_URI=http://localhost
442
431
  ```
443
432
 
444
- ### Example 3: Fetch Related User Data
433
+ | Variable | Default | Description |
434
+ | ----------------------------- | ------------------ | ----------------------------------------- |
435
+ | `OAUTH_MOCK` | `false` | Set to `true` to enable mock mode |
436
+ | `OAUTH_MOCK_USER_ID` | `mock-user-001` | Mock user ID |
437
+ | `OAUTH_MOCK_USER_ROLES` | `admin` | Comma-separated mock roles |
438
+ | `OAUTH_MOCK_USER_PERMISSIONS` | _(empty)_ | Comma-separated mock permissions |
439
+ | `OAUTH_MOCK_USER_IS_VERIFIED` | `1` | Mock verification status (`0` or `1`) |
440
+ | `OAUTH_MOCK_ENDPOINT` | `http://localhost` | Endpoint value returned in token response |
441
+ | `OAUTH_MOCK_REDIRECT_URI` | `http://localhost` | Origin to redirect after mock login |
445
442
 
446
- ```typescript
447
- app.get('/api/followers', async (req: any, res) => {
448
- try {
449
- // Get list of follower IDs from your local database
450
- const followerIds = await db.query(
451
- 'SELECT follower_id FROM relationships WHERE leader_id = ?',
452
- [req.jwtUser.userId]
453
- )
454
-
455
- // Get detailed info for all followers from OAuth provider
456
- const followerInfo = await req.sumor.getUsersInfo(followerIds.map(r => r.follower_id))
457
-
458
- res.json(followerInfo)
459
- } catch (error) {
460
- res.status(500).json({ error: error.message })
461
- }
462
- })
463
- ```
443
+ ### Mock Routes (registered only when `OAUTH_MOCK=true`)
464
444
 
465
- ### Example 4: User Search with Pagination
445
+ | Method | Path | Description |
446
+ | ------ | ----------------------------- | ----------------------------------------------------------- |
447
+ | `POST` | `/api/oauth/mock/login` | Issue mock tokens and return user info (no redirect needed) |
448
+ | `POST` | `/api/oauth/mock/logout` | Clear mock token cookies |
449
+ | `GET` | `/api/oauth/mock/avatar/:id` | Returns 404 (no avatar service in mock mode) |
450
+ | `GET` | `/api/oauth/mock/nav/:target` | Placeholder page for OAuth provider navigation links |
466
451
 
467
- ```typescript
468
- app.get('/api/search/users', async (req: any, res) => {
469
- const { q, limit = 20 } = req.query
452
+ ### Example Mock Setup
470
453
 
471
- if (!q) {
472
- return res.status(400).json({ error: 'Search term required' })
473
- }
454
+ **.env.development:**
474
455
 
475
- try {
476
- const results = await req.sumor.searchUsers(q, limit)
477
- res.json(results)
478
- } catch (error) {
479
- res.status(500).json({ error: error.message })
480
- }
481
- })
456
+ ```bash
457
+ OAUTH_MOCK=true
458
+ OAUTH_MOCK_USER_ID=dev-user-1
459
+ OAUTH_MOCK_USER_ROLES=admin
460
+ OAUTH_MOCK_USER_PERMISSIONS=posts:view,posts:edit,posts:delete
461
+ OAUTH_MOCK_USER_IS_VERIFIED=1
482
462
  ```
483
463
 
484
- ## 🛡️ Security Considerations
464
+ No changes are needed in application code — the same `oauthRoutes`, `loadJwtUserMiddleware`, `login()`, and `logout()` calls work in both mock and production modes.
485
465
 
486
- ### Token Storage
466
+ ### Security Notice
487
467
 
488
- - Tokens are stored in HTTP-only cookies by default (secure against XSS)
489
- - Always use HTTPS in production to prevent token interception
490
- - Refresh tokens should be rotated regularly
468
+ Mock tokens are signed with a fixed HS256 secret and are identified by `iss: 'mock-oauth'` in the payload. **Never use `OAUTH_MOCK=true` in production.**
491
469
 
492
- ### Permission Validation
470
+ ---
493
471
 
494
- Always validate permissions on sensitive operations:
472
+ ## Usage Examples
495
473
 
496
- ```typescript
497
- const userPermissions = req.jwtUser.permissions?.split(',') || []
474
+ ### Server — Protect a Route with Permission Check
498
475
 
499
- if (!userPermissions.includes('users:edit')) {
500
- return res.status(403).json({ error: 'User edit permission required' })
501
- }
502
- ```
503
-
504
- ### Session Revocation
476
+ ```typescript
477
+ app.post('/api/posts', (req, res) => {
478
+ const permissions = req.jwtUser.permissions?.split(',') ?? []
505
479
 
506
- Logout invalidates tokens immediately:
480
+ if (!permissions.includes('posts:create')) {
481
+ return res.status(403).json({ code: 'FORBIDDEN', message: 'Insufficient permissions' })
482
+ }
507
483
 
508
- ```typescript
509
- // On logout
510
- await req.sumor.revokeSession(req.jwtUser.jti)
511
- // Token is added to blacklist and becomes invalid
484
+ // Create post...
485
+ res.json({ code: 'OK', data: { id: 'new-post-id' } })
486
+ })
512
487
  ```
513
488
 
514
- ### CSRF Protection
515
-
516
- Implement CSRF tokens for state-changing operations:
489
+ ### Server — Fetch User Details from OAuth Provider
517
490
 
518
491
  ```typescript
519
- const csrf = require('csurf')
520
- const csrfProtection = csrf({ cookie: false })
492
+ app.get('/api/posts/:id/author', async (req, res) => {
493
+ const post = await db.findPost(req.params.id)
494
+ const author = await oauthService.getUserInfo(post.authorId)
521
495
 
522
- app.post('/api/posts', csrfProtection, (req: any, res) => {
523
- // Handle POST with CSRF protection
496
+ res.json({ code: 'OK', data: author })
524
497
  })
525
498
  ```
526
499
 
527
- ## 🐛 Troubleshooting
528
-
529
- ### "Token verification failed"
530
-
531
- **Cause:** JWT signature doesn't match JWKS public key
532
-
533
- **Solution:** Ensure `OAUTH_ENDPOINT` is correctly configured. Sumor automatically fetches JWKS from `{OAUTH_ENDPOINT}/api/oauth/jwks`
500
+ ### Web — Vue Component with Login/Logout
534
501
 
535
- ### "Unauthorized" on protected routes
502
+ ```typescript
503
+ import { login, logout, oauthStore, hasPermission } from 'sumor/web'
504
+ import { ref, onMounted } from 'vue'
536
505
 
537
- **Cause:** Missing or invalid JWT token in request
506
+ const user = ref(oauthStore.getUser())
538
507
 
539
- **Solution:** Client must include Authorization header:
508
+ onMounted(() => {
509
+ oauthStore.onUserChange(u => {
510
+ user.value = u
511
+ })
512
+ })
540
513
 
514
+ const canEdit = () => hasPermission('posts', 'edit')
541
515
  ```
542
- Authorization: Bearer <JWT_TOKEN>
516
+
517
+ ```html
518
+ <template>
519
+ <button v-if="!user" @click="login">Login</button>
520
+ <button v-else @click="logout">Logout</button>
521
+ <button v-if="canEdit()" @click="editPost">Edit</button>
522
+ </template>
543
523
  ```
544
524
 
545
- ### "Session not found" when revoking
525
+ ### Web Vue Router Guard
546
526
 
547
- **Cause:** Session ID (jti) is invalid or already revoked
527
+ ```typescript
528
+ import { refreshToken, hasPermission } from 'sumor/web'
548
529
 
549
- **Solution:** Check that `req.jwtUser.jti` contains a valid session ID
530
+ // Restore user state before mounting router
531
+ await refreshToken()
550
532
 
551
- ### Permission check always fails
533
+ router.beforeEach(to => {
534
+ if (to.meta.requiresPermission) {
535
+ const [module, operation] = (to.meta.requiresPermission as string).split(':')
536
+ if (!hasPermission(module, operation)) {
537
+ return '/403'
538
+ }
539
+ }
540
+ })
541
+ ```
552
542
 
553
- **Cause:** Permissions string format is incorrect
543
+ ---
554
544
 
555
- **Solution:** Permissions are comma-separated strings. Parse correctly:
545
+ ## Security Considerations
556
546
 
557
- ```typescript
558
- const permissions = req.jwtUser.permissions?.split(',').map(p => p.trim()) || []
559
- ```
547
+ - Tokens are stored in **HTTP-only cookies** and never exposed to JavaScript (XSS protection)
548
+ - Always use **HTTPS** in production
549
+ - JWT signatures are verified against JWKS public keys fetched from the OAuth provider
550
+ - Logout immediately **blacklists** the session on the OAuth provider
551
+ - Never enable `OAUTH_MOCK=true` outside of local development
560
552
 
561
- ## 🔄 Architecture
553
+ ---
562
554
 
563
- ```
564
- ┌──────────────────────────────────────┐
565
- │ Browser / Mobile Client │
566
- └───────────────┬──────────────────────┘
567
- │ (1) Click Login
568
-
569
- ┌──────────────────────────────────────┐
570
- │ Your Express App (Port 3000) │
571
- │ ┌────────────────────────────────┐ │
572
- │ │ PUT /api/oauth/token │ │ (2) Get OAuth URL & User Info
573
- │ │ GET /api/oauth/callback │ │
574
- │ │ POST /api/oauth/logout │ │
575
- │ └────────────────────────────────┘ │
576
- └───────────┬──────────────────────────┘
577
- │ (3) Redirect to OAuth
578
-
579
- ┌──────────────────────────────────────┐
580
- │ OAuth Provider │
581
- │ {OAUTH_ENDPOINT}/api/oauth/... │
582
- │ - Issue JWT tokens │
583
- │ - Manage users & permissions │
584
- │ - Provide JWKS public keys │
585
- └──────────────────────────────────────┘
586
-
587
- │ (4) Verify JWT
588
-
589
- req.sumor
590
- ```
555
+ ## Troubleshooting
591
556
 
592
- **Flow Steps:**
557
+ ### `req.jwtUser` is `undefined`
593
558
 
594
- 1. User clicks "Login" on your app
595
- 2. App calls `PUT /api/oauth/token` with refresh token to get OAuth provider URL and user info
596
- 3. User redirected to OAuth provider
597
- 4. OAuth provider authenticates and redirects back to `/api/oauth/callback` with authorization code
598
- 5. Server exchanges code for JWT token via OAuth API
599
- 6. Server verifies JWT signature using OAuth JWKS public keys
600
- 7. Subsequent requests include JWT in Authorization header
601
- 8. Middleware validates JWT and extracts user info (userId, roles, permissions)
602
- 9. Routes access user info via `req.jwtUser` and call OAuth service via `req.sumor`
559
+ Ensure that `loadJwtUserMiddleware` is registered before the routes that access `req.jwtUser`.
603
560
 
604
- ## 🤝 Contributing
561
+ ### Token verification fails
605
562
 
606
- Contributions are welcome! Please feel free to submit issues and pull requests.
563
+ Ensure `OAUTH_ENDPOINT` points to the correct OAuth provider. Sumor fetches JWKS from `{OAUTH_ENDPOINT}/api/oauth/jwks`.
607
564
 
608
- ## License
565
+ ### `refreshToken()` returns without setting a user
609
566
 
610
- MIT License - see [LICENSE](LICENSE) for details
567
+ The refresh token cookie may be absent or expired. The user needs to log in again via `login()`.
611
568
 
612
- ## 📞 Support
569
+ ### 401 on protected routes in mock mode
613
570
 
614
- For issues, questions, or suggestions, please open an issue on GitHub.
571
+ Ensure `OAUTH_MOCK=true` is set both when the server starts and when checking tokens. Restart the server after changing this variable.
615
572
 
616
573
  ---
617
574
 
618
- Made with ❤️ by [Lycoo](https://lycoo.com)
575
+ ## License
576
+
577
+ MIT License — see [LICENSE](LICENSE) for details.