@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 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
+ }