@vobs/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 +60 -0
- package/package.json +25 -0
- package/src/index.test.ts +186 -0
- package/src/index.ts +227 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
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,60 @@
|
|
|
1
|
+
# @vobs/auth
|
|
2
|
+
|
|
3
|
+
Reactive session state with role and permission checks, declarative access-control boundaries, and a typed error model.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createAuth } from '@vobs/auth'
|
|
15
|
+
|
|
16
|
+
const auth = createAuth({
|
|
17
|
+
loginHandler: async credentials => ({
|
|
18
|
+
user: {
|
|
19
|
+
id: String(credentials.id),
|
|
20
|
+
roles: ['editor'],
|
|
21
|
+
permissions: ['article:read', 'article:edit']
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await auth.login({ id: 7 })
|
|
27
|
+
auth.status.value // 'authenticated'
|
|
28
|
+
auth.hasPermission('article:edit') // true
|
|
29
|
+
auth.requirePermission('article:delete') // throws AuthError with code 'PERMISSION_DENIED'
|
|
30
|
+
auth.logout()
|
|
31
|
+
auth.dispose()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
| Signature | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `createAuth(options?: AuthOptions<C>): AuthContext<C>` | Creates an auth context; owns a session signal unless `options.session` is provided. |
|
|
39
|
+
| `auth.session` | Reactive session signal, `null` when anonymous. |
|
|
40
|
+
| `auth.status: Signal<AuthStatus>` | `'anonymous'` or `'authenticated'`, derived from the session. |
|
|
41
|
+
| `auth.login(credentials: C): Promise<void>` | Calls `loginHandler`, validates the returned session, and stores it. |
|
|
42
|
+
| `auth.logout(): void` | Clears the session. |
|
|
43
|
+
| `auth.hasPermission(permission: Permission): boolean` | Checks the current user's permissions. |
|
|
44
|
+
| `auth.hasRole(role: string): boolean` | Checks the current user's roles. |
|
|
45
|
+
| `auth.requirePermission(permission: Permission): void` | Throws `AuthError('PERMISSION_DENIED')` when the permission is missing. |
|
|
46
|
+
| `auth.requireRole(role: string): void` | Throws `AuthError('PERMISSION_DENIED')` when the role is missing. |
|
|
47
|
+
| `auth.dispose(): void` | Disposes the session and status signals. |
|
|
48
|
+
| `authPlugin(options?: AuthPluginOptions<C>): VobsPlugin` | Provides the context through `AUTH_KEY` and disposes it with the app. |
|
|
49
|
+
| `useAuth(): AuthContext` | Injects the auth context inside components. |
|
|
50
|
+
| `RequireAuth(props?: AuthBoundaryProps): VobsNode` | Renders `children` while a session exists, otherwise `fallback`. |
|
|
51
|
+
| `RequirePermission(props: RequirePermissionProps): VobsNode` | Renders `children` when the permission is granted. |
|
|
52
|
+
| `RequireRole(props: RequireRoleProps): VobsNode` | Renders `children` when the role is granted. |
|
|
53
|
+
| `RequireAnyPermission(props: RequirePermissionsProps): VobsNode` | Renders `children` when at least one permission is granted. |
|
|
54
|
+
| `RequireAllPermissions(props: RequirePermissionsProps): VobsNode` | Renders `children` when every permission is granted. |
|
|
55
|
+
|
|
56
|
+
`login` validates that `loginHandler` returns a session with `user.id`, `user.roles`, and `user.permissions`, throwing `AuthError('INVALID_SESSION')` otherwise. All boundaries re-render reactively when the session signal changes.
|
|
57
|
+
|
|
58
|
+
## Types
|
|
59
|
+
|
|
60
|
+
`AuthContext`, `AuthOptions`, `AuthPluginOptions`, `AuthStatus`, `AuthErrorCode`, `Session`, `SessionUser`, `Role`, `Permission`, `Credentials`, `AuthBoundaryProps`, `RequirePermissionProps`, `RequireRoleProps`, `RequirePermissionsProps`
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/auth",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@vobs/vobs": "1.0.0",
|
|
18
|
+
"@vobs/reactivity": "1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@vobs/resource": "1.0.0",
|
|
22
|
+
"@vobs/http": "1.0.0",
|
|
23
|
+
"@vobs/router": "1.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createElement, createText, createVobs, insertBefore, setRenderer, createDOMRenderer } from '@vobs/vobs'
|
|
3
|
+
import { createHTTPClient } from '@vobs/http'
|
|
4
|
+
import { createResourceClient } from '@vobs/resource'
|
|
5
|
+
import { createMemoryHistory, createRouter } from '@vobs/router'
|
|
6
|
+
import {
|
|
7
|
+
AUTH_KEY,
|
|
8
|
+
AuthError,
|
|
9
|
+
RequireAnyPermission,
|
|
10
|
+
RequireAuth,
|
|
11
|
+
RequirePermission,
|
|
12
|
+
authPlugin,
|
|
13
|
+
createAuth,
|
|
14
|
+
useAuth
|
|
15
|
+
} from './index'
|
|
16
|
+
|
|
17
|
+
describe('@vobs/auth', () => {
|
|
18
|
+
it('登录、登出和权限判断保持响应式 session', async () => {
|
|
19
|
+
const auth = createAuth({
|
|
20
|
+
loginHandler: async credentials => ({
|
|
21
|
+
user: {
|
|
22
|
+
id: String(credentials.id),
|
|
23
|
+
roles: ['editor'],
|
|
24
|
+
permissions: ['article:read', 'article:edit']
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(auth.status.value).toBe('anonymous')
|
|
30
|
+
expect(auth.hasPermission('article:read')).toBe(false)
|
|
31
|
+
await auth.login({ id: 7 })
|
|
32
|
+
expect(auth.status.value).toBe('authenticated')
|
|
33
|
+
expect(auth.hasRole('editor')).toBe(true)
|
|
34
|
+
expect(auth.hasPermission('article:edit')).toBe(true)
|
|
35
|
+
auth.requirePermission('article:read')
|
|
36
|
+
auth.logout()
|
|
37
|
+
expect(auth.status.value).toBe('anonymous')
|
|
38
|
+
expect(auth.hasRole('editor')).toBe(false)
|
|
39
|
+
auth.dispose()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('未配置登录处理器、无权限和无效 session 都给出 AuthError', async () => {
|
|
43
|
+
const auth = createAuth()
|
|
44
|
+
await expect(auth.login({})).rejects.toMatchObject({ code: 'LOGIN_NOT_CONFIGURED' })
|
|
45
|
+
expect(() => auth.requirePermission('admin')).toThrowError(
|
|
46
|
+
expect.objectContaining({ code: 'PERMISSION_DENIED' })
|
|
47
|
+
)
|
|
48
|
+
const invalid = createAuth({ loginHandler: () => ({ user: { id: '1' } } as never) })
|
|
49
|
+
await expect(invalid.login({})).rejects.toMatchObject({ code: 'INVALID_SESSION' })
|
|
50
|
+
auth.dispose()
|
|
51
|
+
invalid.dispose()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('authPlugin 注入上下文,并在应用销毁时清理自有上下文', () => {
|
|
55
|
+
let injected: unknown
|
|
56
|
+
const app = createVobs({
|
|
57
|
+
render: () => createText('app'),
|
|
58
|
+
plugins: [{
|
|
59
|
+
name: 'consumer',
|
|
60
|
+
requires: [authPlugin()],
|
|
61
|
+
install(context) { injected = context.inject(AUTH_KEY) }
|
|
62
|
+
}]
|
|
63
|
+
})
|
|
64
|
+
expect(injected).toBeDefined()
|
|
65
|
+
app.destroy()
|
|
66
|
+
expect(() => (injected as ReturnType<typeof createAuth>).logout()).toThrow('已销毁')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('权限边界默认拒绝,并在 session 变化后更新子树', () => {
|
|
70
|
+
setRenderer(createDOMRenderer())
|
|
71
|
+
const auth = createAuth()
|
|
72
|
+
const container = document.createElement('div')
|
|
73
|
+
const app = createVobs({
|
|
74
|
+
render: () => {
|
|
75
|
+
const root = createElement('main')
|
|
76
|
+
insertBefore(root, RequireAuth({ children: () => createText('signed-in') }), null)
|
|
77
|
+
insertBefore(root, RequirePermission({
|
|
78
|
+
permission: 'article:edit',
|
|
79
|
+
children: () => createText('edit')
|
|
80
|
+
}), null)
|
|
81
|
+
insertBefore(root, RequireAnyPermission({
|
|
82
|
+
permissions: ['article:read', 'article:edit'],
|
|
83
|
+
children: () => createText('read-or-edit')
|
|
84
|
+
}), null)
|
|
85
|
+
return root
|
|
86
|
+
},
|
|
87
|
+
plugins: [authPlugin({ auth })]
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
app.mount(container)
|
|
91
|
+
expect(container.textContent).toBe('')
|
|
92
|
+
auth.session.value = { user: { id: '1', roles: [], permissions: ['article:edit'] } }
|
|
93
|
+
app.update()
|
|
94
|
+
expect(container.textContent).toBe('signed-ineditread-or-edit')
|
|
95
|
+
auth.logout()
|
|
96
|
+
app.update()
|
|
97
|
+
expect(container.textContent).toBe('')
|
|
98
|
+
app.destroy()
|
|
99
|
+
auth.dispose()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('可配合 Router 守卫拒绝匿名访问并重定向登录页', async () => {
|
|
103
|
+
const auth = createAuth()
|
|
104
|
+
const router = createRouter({
|
|
105
|
+
history: createMemoryHistory('/'),
|
|
106
|
+
routes: [
|
|
107
|
+
{ path: '/', component: () => createText('home') },
|
|
108
|
+
{ path: '/login', component: () => createText('login') },
|
|
109
|
+
{ path: '/private', component: () => createText('private'), meta: { requiresAuth: true } }
|
|
110
|
+
]
|
|
111
|
+
})
|
|
112
|
+
router.beforeEach(to => to.meta.requiresAuth && !auth.session.value ? '/login' : undefined)
|
|
113
|
+
|
|
114
|
+
await expect(router.push('/private')).resolves.toMatchObject({ path: '/login' })
|
|
115
|
+
auth.session.value = { user: { id: '1', roles: [], permissions: [] } }
|
|
116
|
+
await expect(router.push('/private')).resolves.toMatchObject({ path: '/private' })
|
|
117
|
+
router.destroy()
|
|
118
|
+
auth.dispose()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('可用 HTTP 请求拦截器读取当前 session 并在登出后停止携带凭证', async () => {
|
|
122
|
+
const auth = createAuth()
|
|
123
|
+
const seen: string[] = []
|
|
124
|
+
const client = createHTTPClient({
|
|
125
|
+
adapter: config => {
|
|
126
|
+
seen.push(config.headers.Authorization ?? '')
|
|
127
|
+
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
client.interceptors.request.use(config => {
|
|
131
|
+
const user = auth.session.value?.user
|
|
132
|
+
if (user) config.headers.Authorization = `Session ${user.id}`
|
|
133
|
+
return config
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await client.get('/me')
|
|
137
|
+
auth.session.value = { user: { id: '42', roles: [], permissions: [] } }
|
|
138
|
+
await client.get('/me')
|
|
139
|
+
auth.logout()
|
|
140
|
+
await client.get('/me')
|
|
141
|
+
expect(seen).toEqual(['', 'Session 42', ''])
|
|
142
|
+
auth.dispose()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('Resource fetcher 复用带 Auth session 的 HTTP 请求拦截器', async () => {
|
|
146
|
+
const auth = createAuth()
|
|
147
|
+
const client = createHTTPClient({
|
|
148
|
+
adapter: config => new Response(JSON.stringify({
|
|
149
|
+
id: config.headers.Authorization?.replace('Session ', '') ?? 'anonymous'
|
|
150
|
+
}), {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: { 'content-type': 'application/json' }
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
client.interceptors.request.use(config => {
|
|
156
|
+
const user = auth.session.value?.user
|
|
157
|
+
if (user) config.headers.Authorization = `Session ${user.id}`
|
|
158
|
+
return config
|
|
159
|
+
})
|
|
160
|
+
const resources = createResourceClient()
|
|
161
|
+
const profile = resources.resource({
|
|
162
|
+
key: ['profile'],
|
|
163
|
+
fetcher: signal => client.get<{ id: string }>('/me', { signal }).then(response => response.data)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
await profile.refetch()
|
|
167
|
+
expect(profile.data.value).toEqual({ id: 'anonymous' })
|
|
168
|
+
auth.session.value = { user: { id: '42', roles: [], permissions: [] } }
|
|
169
|
+
resources.invalidate(['profile'])
|
|
170
|
+
await profile.refetch()
|
|
171
|
+
expect(profile.data.value).toEqual({ id: '42' })
|
|
172
|
+
resources.clear()
|
|
173
|
+
auth.dispose()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('未安装插件时 useAuth 抛出明确错误', () => {
|
|
177
|
+
const app = createVobs({ render: () => {
|
|
178
|
+
useAuth()
|
|
179
|
+
return createText('')
|
|
180
|
+
} })
|
|
181
|
+
expect(() => app.mount(document.createElement('div'))).toThrowError(
|
|
182
|
+
expect.objectContaining({ code: 'AUTH_CONTEXT_MISSING' })
|
|
183
|
+
)
|
|
184
|
+
expect(AuthError).toBeDefined()
|
|
185
|
+
})
|
|
186
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { getCurrentOwner, memo, onDispose, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createFragment,
|
|
4
|
+
createInjectionKey,
|
|
5
|
+
inject,
|
|
6
|
+
insertDynamic,
|
|
7
|
+
type InjectionKey,
|
|
8
|
+
type VobsNode,
|
|
9
|
+
type VobsPlugin
|
|
10
|
+
} from '@vobs/vobs'
|
|
11
|
+
|
|
12
|
+
export type Permission = string
|
|
13
|
+
|
|
14
|
+
export interface Role {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly permissions: readonly Permission[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionUser {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly roles: readonly string[]
|
|
22
|
+
readonly permissions: readonly Permission[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Session {
|
|
26
|
+
readonly user: SessionUser
|
|
27
|
+
readonly [key: string]: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Credentials = Record<string, unknown>
|
|
31
|
+
export type AuthStatus = 'anonymous' | 'authenticated'
|
|
32
|
+
|
|
33
|
+
export type AuthErrorCode =
|
|
34
|
+
| 'AUTH_CONTEXT_MISSING'
|
|
35
|
+
| 'LOGIN_NOT_CONFIGURED'
|
|
36
|
+
| 'INVALID_SESSION'
|
|
37
|
+
| 'AUTH_REQUIRED'
|
|
38
|
+
| 'PERMISSION_DENIED'
|
|
39
|
+
|
|
40
|
+
export class AuthError extends Error {
|
|
41
|
+
readonly code: AuthErrorCode
|
|
42
|
+
|
|
43
|
+
constructor(code: AuthErrorCode, message: string) {
|
|
44
|
+
super(message)
|
|
45
|
+
this.name = 'AuthError'
|
|
46
|
+
this.code = code
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AuthContext<C extends Credentials = Credentials> {
|
|
51
|
+
readonly session: Signal<Session | null>
|
|
52
|
+
readonly status: Signal<AuthStatus>
|
|
53
|
+
hasPermission(permission: Permission): boolean
|
|
54
|
+
hasRole(role: string): boolean
|
|
55
|
+
login(credentials: C): Promise<void>
|
|
56
|
+
logout(): void
|
|
57
|
+
requirePermission(permission: Permission): void
|
|
58
|
+
requireRole(role: string): void
|
|
59
|
+
dispose(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AuthOptions<C extends Credentials = Credentials> {
|
|
63
|
+
readonly session?: Signal<Session | null>
|
|
64
|
+
readonly loginHandler?: (credentials: C) => Session | PromiseLike<Session>
|
|
65
|
+
readonly roles?: Readonly<Record<string, Role>>
|
|
66
|
+
readonly permissions?: Readonly<Record<string, Permission>>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuthPluginOptions<C extends Credentials = Credentials> extends AuthOptions<C> {
|
|
70
|
+
readonly auth?: AuthContext<C>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const AUTH_KEY: InjectionKey<AuthContext> = createInjectionKey<AuthContext>('vobs.auth')
|
|
74
|
+
|
|
75
|
+
export interface AuthBoundaryProps {
|
|
76
|
+
readonly fallback?: VobsNode | (() => VobsNode | null | undefined)
|
|
77
|
+
readonly children?: VobsNode | (() => VobsNode | null | undefined)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RequirePermissionProps extends AuthBoundaryProps {
|
|
81
|
+
readonly permission: Permission
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RequireRoleProps extends AuthBoundaryProps {
|
|
85
|
+
readonly role: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RequirePermissionsProps extends AuthBoundaryProps {
|
|
89
|
+
readonly permissions: readonly Permission[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createAuth<C extends Credentials = Credentials>(options: AuthOptions<C> = {}): AuthContext<C> {
|
|
93
|
+
const ownedSession = options.session ? undefined : state<Session | null>(null)
|
|
94
|
+
const session = options.session ?? ownedSession!
|
|
95
|
+
const status = memo<AuthStatus>(() => session.value ? 'authenticated' : 'anonymous')
|
|
96
|
+
let disposed = false
|
|
97
|
+
|
|
98
|
+
const context: AuthContext<C> = {
|
|
99
|
+
session,
|
|
100
|
+
status,
|
|
101
|
+
|
|
102
|
+
hasPermission(permission: Permission): boolean {
|
|
103
|
+
if (disposed || !permission) return false
|
|
104
|
+
return session.value?.user.permissions.includes(permission) ?? false
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
hasRole(role: string): boolean {
|
|
108
|
+
if (disposed || !role) return false
|
|
109
|
+
return session.value?.user.roles.includes(role) ?? false
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async login(credentials: C): Promise<void> {
|
|
113
|
+
ensureActive()
|
|
114
|
+
if (!options.loginHandler) {
|
|
115
|
+
throw new AuthError('LOGIN_NOT_CONFIGURED', 'Vobs Auth: 未配置 loginHandler')
|
|
116
|
+
}
|
|
117
|
+
const nextSession = await options.loginHandler(credentials)
|
|
118
|
+
validateSession(nextSession)
|
|
119
|
+
session.value = nextSession
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
logout(): void {
|
|
123
|
+
ensureActive()
|
|
124
|
+
session.value = null
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
requirePermission(permission: Permission): void {
|
|
128
|
+
ensureActive()
|
|
129
|
+
if (!context.hasPermission(permission)) {
|
|
130
|
+
throw new AuthError('PERMISSION_DENIED', `Vobs Auth: 缺少权限 ${permission}`)
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
requireRole(role: string): void {
|
|
135
|
+
ensureActive()
|
|
136
|
+
if (!context.hasRole(role)) {
|
|
137
|
+
throw new AuthError('PERMISSION_DENIED', `Vobs Auth: 缺少角色 ${role}`)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
dispose(): void {
|
|
142
|
+
if (disposed) return
|
|
143
|
+
disposed = true
|
|
144
|
+
status.dispose()
|
|
145
|
+
ownedSession?.dispose()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (ownedSession && getCurrentOwner()) onDispose(context.dispose)
|
|
150
|
+
return context
|
|
151
|
+
|
|
152
|
+
function ensureActive(): void {
|
|
153
|
+
if (disposed) throw new AuthError('AUTH_CONTEXT_MISSING', 'Vobs Auth: 上下文已销毁')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function authPlugin<C extends Credentials = Credentials>(options: AuthPluginOptions<C> = {}): VobsPlugin {
|
|
158
|
+
return {
|
|
159
|
+
name: '@vobs/auth',
|
|
160
|
+
version: '0.1.0',
|
|
161
|
+
install(context) {
|
|
162
|
+
const ownedAuth = options.auth ? undefined : createAuth(options)
|
|
163
|
+
context.provide(AUTH_KEY, options.auth ?? ownedAuth!)
|
|
164
|
+
return () => ownedAuth?.dispose()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function useAuth(): AuthContext {
|
|
170
|
+
const auth = inject(AUTH_KEY)
|
|
171
|
+
if (!auth) {
|
|
172
|
+
throw new AuthError('AUTH_CONTEXT_MISSING', 'Vobs Auth: 找不到上下文,请安装 authPlugin')
|
|
173
|
+
}
|
|
174
|
+
return auth
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function RequireAuth(props: AuthBoundaryProps = {}): VobsNode {
|
|
178
|
+
return createAuthBoundary(useAuth, auth => Boolean(auth.session.value), props)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function RequirePermission(props: RequirePermissionProps): VobsNode {
|
|
182
|
+
return createAuthBoundary(useAuth, auth => auth.hasPermission(props.permission), props)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function RequireRole(props: RequireRoleProps): VobsNode {
|
|
186
|
+
return createAuthBoundary(useAuth, auth => auth.hasRole(props.role), props)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function RequireAnyPermission(props: RequirePermissionsProps): VobsNode {
|
|
190
|
+
return createAuthBoundary(
|
|
191
|
+
useAuth,
|
|
192
|
+
auth => props.permissions.some(permission => auth.hasPermission(permission)),
|
|
193
|
+
props
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function RequireAllPermissions(props: RequirePermissionsProps): VobsNode {
|
|
198
|
+
return createAuthBoundary(
|
|
199
|
+
useAuth,
|
|
200
|
+
auth => props.permissions.every(permission => auth.hasPermission(permission)),
|
|
201
|
+
props
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createAuthBoundary(
|
|
206
|
+
getAuth: () => AuthContext,
|
|
207
|
+
allowed: (auth: AuthContext) => boolean,
|
|
208
|
+
props: AuthBoundaryProps
|
|
209
|
+
): VobsNode {
|
|
210
|
+
const auth = getAuth()
|
|
211
|
+
return createFragment((parent, anchor) => {
|
|
212
|
+
insertDynamic(parent, anchor, () => {
|
|
213
|
+
const node = allowed(auth) ? props.children : props.fallback
|
|
214
|
+
return typeof node === 'function' ? node() : node
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateSession(value: Session): void {
|
|
220
|
+
if (!value || typeof value !== 'object' || !value.user || typeof value.user !== 'object') {
|
|
221
|
+
throw new AuthError('INVALID_SESSION', 'Vobs Auth: loginHandler 返回了无效 session')
|
|
222
|
+
}
|
|
223
|
+
const user = value.user
|
|
224
|
+
if (typeof user.id !== 'string' || !Array.isArray(user.roles) || !Array.isArray(user.permissions)) {
|
|
225
|
+
throw new AuthError('INVALID_SESSION', 'Vobs Auth: session.user 必须包含 id、roles 和 permissions')
|
|
226
|
+
}
|
|
227
|
+
}
|