From bbe5931591539da271665e45f9ae8cff478721fa Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 5 Aug 2026 10:58:21 +0800 Subject: [PATCH 1/3] feat(auth): require trusted permission resolvers --- docs/auth.md | 7 +++- docs/src/content/docs/hooks/auth.md | 2 + docs/src/content/docs/providers/auth.md | 13 +++++- docs/src/content/docs/providers/sso.md | 18 +++++++-- docs/src/content/docs/zh-cn/hooks/auth.md | 2 + docs/src/content/docs/zh-cn/providers/auth.md | 13 +++++- docs/src/content/docs/zh-cn/providers/sso.md | 18 +++++++-- packages/core/src/auth-hooks.svelte.ts | 4 +- .../permissions.feature-gate.test.svelte.ts | 25 ++++++++++++ packages/core/src/permissions.svelte.ts | 12 ++---- packages/sso/src/auth-provider.test.ts | 34 +++++++++++++++- packages/sso/src/auth-provider.ts | 40 +++++++++++++------ packages/sso/src/index.ts | 2 + packages/supabase/src/auth-provider.ts | 40 +++++++++++++++---- packages/supabase/src/index.ts | 5 +++ packages/supabase/src/supabase.test.ts | 18 +++++++-- 16 files changed, 206 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/permissions.feature-gate.test.svelte.ts diff --git a/docs/auth.md b/docs/auth.md index 0c4b9480..96496c6a 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -83,9 +83,12 @@ mutate(error); // Calls authProvider.onError → may redirect or logout ### `usePermissions()` +`usePermissions()` is a client-side rendering helper. It can hide or disable UI, but APIs, data providers, and database policies must enforce authorization independently. + ```typescript -const { data, isLoading, error } = usePermissions(); -// data: whatever authProvider.getPermissions() returns +const { raw, has, can, isLoading, error } = usePermissions(); +// raw: whatever trusted authProvider.getPermissions() resolver returns +// has/can: exact client-side UI checks ``` ## Auth Pages diff --git a/docs/src/content/docs/hooks/auth.md b/docs/src/content/docs/hooks/auth.md index 1c3a9c92..7e3b34ef 100644 --- a/docs/src/content/docs/hooks/auth.md +++ b/docs/src/content/docs/hooks/auth.md @@ -36,6 +36,8 @@ const { isAuthenticated, isLoading } = useIsAuthenticated(); ### `usePermissions()` +`usePermissions()` is a client-side rendering helper. It can hide or disable UI, but APIs, data providers, and database policies must enforce authorization independently. + ```typescript const { raw, has, can, isLoading, refetch } = usePermissions(); diff --git a/docs/src/content/docs/providers/auth.md b/docs/src/content/docs/providers/auth.md index ccd412d5..ce3e7027 100644 --- a/docs/src/content/docs/providers/auth.md +++ b/docs/src/content/docs/providers/auth.md @@ -21,6 +21,8 @@ interface AuthProvider { } ``` +`getPermissions` is intentionally a UI hint hook. It should return permissions only when the application provides a trusted resolver; it must not replace API, backend, or database authorization. + ## Auth Hooks | Hook | Purpose | @@ -91,10 +93,19 @@ export const mockAuthProvider: AuthProvider = { ```typescript import { createSupabaseAuthProvider } from '@svadmin/supabase'; -const authProvider = createSupabaseAuthProvider(supabaseClient); +const authProvider = createSupabaseAuthProvider(supabaseClient, { + getPermissions: async ({ client }) => { + const { data, error } = await client + .from('effective_permission_grants') + .select('permission'); + if (error) throw error; + return data.map((grant) => grant.permission); + }, +}); ``` `@supacloud/js` does not change the auth flow. Keep using the official Supabase client with `createSupabaseAuthProvider()`, and layer any task APIs separately through [`@svadmin/supabase/supacloud`](/providers/supacloud). +Do not use user-editable metadata as an authorization fact. ### Appwrite diff --git a/docs/src/content/docs/providers/sso.md b/docs/src/content/docs/providers/sso.md index df796a61..ac91487a 100644 --- a/docs/src/content/docs/providers/sso.md +++ b/docs/src/content/docs/providers/sso.md @@ -73,6 +73,8 @@ interface SSOConfig { refreshLock?: RefreshLock; /** Injectable fetch implementation for tests and SSR runtimes. */ fetcher?: typeof fetch; + /** Trusted permission resolver for UI hints. */ + getPermissions?: (context: SSOPermissionResolverContext) => Promise | unknown; } ``` @@ -114,13 +116,21 @@ const authProvider = createSSOAuthProvider({ }); ``` -### Permissions from ID Token +### Trusted Permissions Resolver -The provider automatically extracts `roles`, `groups`, or `permissions` claims from the ID token via `getPermissions()`. +`getPermissions()` returns `null` unless you configure a trusted resolver. Use the resolver to call your application authorization endpoint or another backend-controlled source. ID Token claims can be useful display hints, but they are not a replacement for API or database authorization. ```typescript -const permissions = await authProvider.getPermissions(); -// → ['admin', 'editor'] (from ID token claims) +const authProvider = createSSOAuthProvider({ + issuer: 'https://your-tenant.okta.com', + clientId: 'abc', + redirectUri: '/callback', + getPermissions: async ({ createAuthenticatedFetch }) => { + const response = await createAuthenticatedFetch()(new URL('/api/me/permissions', window.location.origin)); + if (!response.ok) throw new Error('Failed to load permissions'); + return response.json(); + }, +}); ``` ### Calling Protected APIs diff --git a/docs/src/content/docs/zh-cn/hooks/auth.md b/docs/src/content/docs/zh-cn/hooks/auth.md index 4cc7a75b..d62adb3d 100644 --- a/docs/src/content/docs/zh-cn/hooks/auth.md +++ b/docs/src/content/docs/zh-cn/hooks/auth.md @@ -36,6 +36,8 @@ const { isAuthenticated, isLoading } = useIsAuthenticated(); ### `usePermissions()` +`usePermissions()` 只是客户端渲染辅助。它可以隐藏或禁用 UI,但 API、DataProvider 和数据库策略必须独立执行授权。 + ```typescript const { raw, has, can, isLoading, refetch } = usePermissions(); diff --git a/docs/src/content/docs/zh-cn/providers/auth.md b/docs/src/content/docs/zh-cn/providers/auth.md index 853f8e0c..f1e5ef5b 100644 --- a/docs/src/content/docs/zh-cn/providers/auth.md +++ b/docs/src/content/docs/zh-cn/providers/auth.md @@ -21,6 +21,8 @@ interface AuthProvider { } ``` +`getPermissions` 只作为 UI hint。只有应用提供可信 resolver 时才应返回权限;它不能替代 API、后端或数据库授权。 + ## 认证 Hook | Hook | 用途 | @@ -91,10 +93,19 @@ export const mockAuthProvider: AuthProvider = { ```typescript import { createSupabaseAuthProvider } from '@svadmin/supabase'; -const authProvider = createSupabaseAuthProvider(supabaseClient); +const authProvider = createSupabaseAuthProvider(supabaseClient, { + getPermissions: async ({ client }) => { + const { data, error } = await client + .from('effective_permission_grants') + .select('permission'); + if (error) throw error; + return data.map((grant) => grant.permission); + }, +}); ``` `@supacloud/js` 不会改变认证流程。认证部分仍然建议继续使用官方 Supabase 客户端配合 `createSupabaseAuthProvider()`,任务相关 API 再通过 [`@svadmin/supabase/supacloud`](/zh-cn/providers/supacloud) 单独组合接入。 +不要把用户可修改的 metadata 当成授权事实。 ### Appwrite diff --git a/docs/src/content/docs/zh-cn/providers/sso.md b/docs/src/content/docs/zh-cn/providers/sso.md index 6c1ab5e1..ac893abd 100644 --- a/docs/src/content/docs/zh-cn/providers/sso.md +++ b/docs/src/content/docs/zh-cn/providers/sso.md @@ -65,6 +65,8 @@ interface SSOConfig { refreshBuffer?: number; /** 额外授权请求参数,例如 audience 或 prompt */ authorizationParams?: Record; + /** 可信权限 resolver,仅用于 UI hint */ + getPermissions?: (context: SSOPermissionResolverContext) => Promise | unknown; } ``` @@ -98,13 +100,21 @@ const authProvider = createSSOAuthProvider({ }); ``` -### 从 ID Token 提取权限 +### 可信权限 Resolver -自动从 ID Token 的 `roles`、`groups`、`permissions` claim 中提取权限信息。 +未配置可信 resolver 时,`getPermissions()` 返回 `null`。请在 resolver 中调用应用自己的授权接口或后端控制的数据源。ID Token claims 可以作为展示提示,但不能替代 API 或数据库授权。 ```typescript -const permissions = await authProvider.getPermissions(); -// → ['admin', 'editor'](来自 ID Token claims) +const authProvider = createSSOAuthProvider({ + issuer: 'https://your-tenant.okta.com', + clientId: 'abc', + redirectUri: '/callback', + getPermissions: async ({ createAuthenticatedFetch }) => { + const response = await createAuthenticatedFetch()(new URL('/api/me/permissions', window.location.origin)); + if (!response.ok) throw new Error('Failed to load permissions'); + return response.json(); + }, +}); ``` ### 调用受保护 API diff --git a/packages/core/src/auth-hooks.svelte.ts b/packages/core/src/auth-hooks.svelte.ts index febdaedd..b3a19bcd 100644 --- a/packages/core/src/auth-hooks.svelte.ts +++ b/packages/core/src/auth-hooks.svelte.ts @@ -270,7 +270,9 @@ export function useOnError() { /** * Fetches permissions from authProvider.getPermissions(). - * Returns a reactive object with convenience methods for permission checks. + * Returns a reactive UI helper with convenience methods for permission checks. + * API routes, data providers, and database policies must enforce authorization + * independently; this hook only gates client-side rendering. * * Supports `refetch()` for session-level permission refresh (e.g., after role change). * diff --git a/packages/core/src/permissions.feature-gate.test.svelte.ts b/packages/core/src/permissions.feature-gate.test.svelte.ts new file mode 100644 index 00000000..1b27dbf7 --- /dev/null +++ b/packages/core/src/permissions.feature-gate.test.svelte.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { createFeatureGate } from './permissions.svelte'; + +describe('createFeatureGate', () => { + it('requires exact permissions', () => { + const canEditPosts = createFeatureGate({ + permissions: ['posts:edit'], + }); + + expect(canEditPosts({ role: 'admin', permissions: ['posts:edit'] })).toBe(true); + expect(canEditPosts({ role: 'admin', permissions: ['posts:*'] })).toBe(false); + expect(canEditPosts({ role: 'admin', permissions: ['*'] })).toBe(false); + }); + + it('enforces role hierarchy when provided', () => { + const canModerate = createFeatureGate({ + minRole: 'editor', + roleHierarchy: ['admin', 'editor', 'viewer'], + }); + + expect(canModerate({ role: 'admin', permissions: [] })).toBe(true); + expect(canModerate({ role: 'editor', permissions: [] })).toBe(true); + expect(canModerate({ role: 'viewer', permissions: [] })).toBe(false); + }); +}); diff --git a/packages/core/src/permissions.svelte.ts b/packages/core/src/permissions.svelte.ts index 41869060..f00421dc 100644 --- a/packages/core/src/permissions.svelte.ts +++ b/packages/core/src/permissions.svelte.ts @@ -123,7 +123,8 @@ export interface FeatureGateUser { /** * 创建功能门控函数 — 基于角色和权限判断用户是否可访问某功能。 - * 不预设任何角色层级,由调用方完全定义。 + * 只做客户端 UI 门控;真实 API、数据库或 RLS 必须独立授权。 + * 不预设任何角色层级,也不解释通配符权限。 * * @example * ```ts @@ -157,14 +158,7 @@ export function createFeatureGate(config: FeatureGateConfig): (user: FeatureGate } if (config.permissions && config.permissions.length > 0) { - const hasAll = config.permissions.every((permission) => { - if (user.permissions.includes("*")) return true; - if (user.permissions.includes(permission)) return true; - const [resource] = permission.split(":"); - if (resource && user.permissions.includes(`${resource}:*`)) return true; - return false; - }); - if (!hasAll) return false; + if (!config.permissions.every((permission) => user.permissions.includes(permission))) return false; } return true; diff --git a/packages/sso/src/auth-provider.test.ts b/packages/sso/src/auth-provider.test.ts index 5ed48a78..9ba27f59 100644 --- a/packages/sso/src/auth-provider.test.ts +++ b/packages/sso/src/auth-provider.test.ts @@ -264,7 +264,39 @@ describe('createSSOAuthProvider', () => { expect(storage.getItem(`${STORAGE_PREFIX}state`)).toBeNull(); expect(await provider.getAccessToken()).toBe('access-123'); expect((await provider.getSession())?.token_type).toBe('Bearer'); - expect(await provider.getPermissions?.()).toEqual(['admin']); + expect(await provider.getPermissions?.()).toBeNull(); + }); + + test('resolves permissions only through the configured resolver', async () => { + const storage = createMemoryStorage(); + storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, 'verifier-123'); + storage.setItem(`${STORAGE_PREFIX}state`, 'state-123'); + installWindow('http://app.test/callback?code=code-123&state=state-123'); + + installFetch(() => jsonResponse({ + access_token: 'access-123', + id_token: jwt({ roles: ['admin'] }), + refresh_token: 'refresh-123', + expires_in: 3600, + token_type: 'bearer', + })); + const provider = createSSOAuthProvider({ + issuer: 'https://idp.test', + clientId: 'admin-console', + redirectUri: 'http://app.test/callback', + storage, + autoRefresh: false, + manualEndpoints, + getPermissions: async ({ session, getAccessToken }) => { + expect(session.id_token).toBeDefined(); + expect(await getAccessToken()).toBe('access-123'); + return ['admin', 'posts:edit']; + }, + }); + + await provider.check(); + + expect(await provider.getPermissions?.()).toEqual(['admin', 'posts:edit']); }); test('does not restore a callback session after logout cancels an in-flight exchange', async () => { diff --git a/packages/sso/src/auth-provider.ts b/packages/sso/src/auth-provider.ts index 9ef1c37e..e0a1d52b 100644 --- a/packages/sso/src/auth-provider.ts +++ b/packages/sso/src/auth-provider.ts @@ -58,6 +58,8 @@ export interface SSOConfig { refreshLock?: RefreshLock; /** Injectable fetch implementation for testing and SSR runtimes. */ fetcher?: typeof fetch; + /** Trusted permission resolver. Without one, getPermissions() returns null. */ + getPermissions?: SSOPermissionResolver; /** * Manual OAuth2 endpoints for providers that don't support OIDC discovery * (e.g., GitHub). When provided, OIDC auto-discovery is skipped. @@ -76,6 +78,16 @@ export interface TokenStorage { removeItem: (key: string) => void; } +export interface SSOPermissionResolverContext { + session: SSOSession; + getAccessToken: (options?: GetAccessTokenOptions) => Promise; + createAuthenticatedFetch: (fetcher?: typeof fetch) => typeof fetch; +} + +export type SSOPermissionResolver = ( + context: SSOPermissionResolverContext, +) => Promise | unknown; + export interface SSOAuthProvider extends AuthProvider { getSession: () => Promise; refreshSession: () => Promise; @@ -511,6 +523,13 @@ export function createSSOAuthProvider(config: SSOConfig): SSOAuthProvider { }, }; + function createProviderAuthenticatedFetch(fetcher?: typeof fetch): typeof fetch { + return buildAuthenticatedFetch( + authorizationSource, + fetcher ?? getFetcher(), + ); + } + const provider: SSOAuthProvider = { async login() { if (typeof window === 'undefined') { @@ -713,25 +732,20 @@ export function createSSOAuthProvider(config: SSOConfig): SSOAuthProvider { }, async getPermissions() { - const idToken = sessions.getSession()?.id_token; - if (!idToken) return null; - - try { - const payload = decodeJwtPayload(idToken); - return payload?.roles ?? payload?.groups ?? payload?.permissions ?? null; - } catch { - return null; - } + const session = sessions.getSession(); + if (!session || !config.getPermissions) return null; + return config.getPermissions({ + session, + getAccessToken: (options) => sessions.getAccessToken(options), + createAuthenticatedFetch: createProviderAuthenticatedFetch, + }); }, getSession: async () => sessions.getSession(), refreshSession: () => sessions.refreshSession(), getAccessToken: (options) => sessions.getAccessToken(options), onAuthStateChange: (callback) => sessions.onAuthStateChange(callback), - createAuthenticatedFetch: (fetcher) => buildAuthenticatedFetch( - authorizationSource, - fetcher ?? getFetcher(), - ), + createAuthenticatedFetch: createProviderAuthenticatedFetch, destroy: () => sessions.destroy(), async onError(error) { diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts index e7f87ad2..aab1da48 100644 --- a/packages/sso/src/index.ts +++ b/packages/sso/src/index.ts @@ -13,6 +13,8 @@ export type { AuthStateChangeEvent, GetAccessTokenOptions, RefreshLock, + SSOPermissionResolver, + SSOPermissionResolverContext, SSOAuthProvider, SSOConfig, SSOSession, diff --git a/packages/supabase/src/auth-provider.ts b/packages/supabase/src/auth-provider.ts index eaef0cd5..803c26b3 100644 --- a/packages/supabase/src/auth-provider.ts +++ b/packages/supabase/src/auth-provider.ts @@ -1,8 +1,21 @@ // Supabase AuthProvider -import type { SupabaseClient } from '@supabase/supabase-js'; +import type { SupabaseClient, User } from '@supabase/supabase-js'; import type { AuthProvider, Identity, AuthActionResult, CheckResult } from '@svadmin/core'; import { audit } from '@svadmin/core'; +export interface SupabasePermissionResolverContext { + client: SupabaseClient; + user: User; +} + +export type SupabasePermissionResolver = ( + context: SupabasePermissionResolverContext, +) => Promise | unknown; + +export interface SupabaseAuthProviderOptions { + getPermissions?: SupabasePermissionResolver; +} + const INVALID_REFRESH_TOKEN_MESSAGES = [ 'refresh token is not valid', 'invalid refresh token', @@ -23,7 +36,20 @@ async function clearInvalidSession(client: SupabaseClient): Promise { await client.auth.signOut({ scope: 'local' }); } -export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider { +async function currentPermissionUser(client: SupabaseClient): Promise { + const { data: { user }, error } = await client.auth.getUser(); + if (error && isInvalidRefreshTokenError(error)) { + await clearInvalidSession(client); + return null; + } + if (error) return null; + return user ?? null; +} + +export function createSupabaseAuthProvider( + client: SupabaseClient, + options: SupabaseAuthProviderOptions = {}, +): AuthProvider { return { async login({ email, password }: Record): Promise { const { data, error } = await client.auth.signInWithPassword({ @@ -90,12 +116,10 @@ export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider }, async getPermissions(): Promise { - const { data: { user }, error } = await client.auth.getUser(); - if (error && isInvalidRefreshTokenError(error)) { - await clearInvalidSession(client); - return null; - } - return user?.user_metadata?.role ?? 'user'; + if (!options.getPermissions) return null; + const user = await currentPermissionUser(client); + if (!user) return null; + return options.getPermissions({ client, user }); }, async register({ email, password, ...rest }: Record): Promise { diff --git a/packages/supabase/src/index.ts b/packages/supabase/src/index.ts index 09b0ba29..fe4f6565 100644 --- a/packages/supabase/src/index.ts +++ b/packages/supabase/src/index.ts @@ -2,6 +2,11 @@ export { createSupabaseDataProvider } from './data-provider'; export { createSupabaseAuthProvider } from './auth-provider'; +export type { + SupabaseAuthProviderOptions, + SupabasePermissionResolver, + SupabasePermissionResolverContext, +} from './auth-provider'; export { createSupabaseLiveProvider } from './live-provider'; export { createSupabaseAuditHandler } from './audit-handler'; export { diff --git a/packages/supabase/src/supabase.test.ts b/packages/supabase/src/supabase.test.ts index 824c6cbb..e6a790e2 100644 --- a/packages/supabase/src/supabase.test.ts +++ b/packages/supabase/src/supabase.test.ts @@ -165,11 +165,23 @@ describe('Supabase AuthProvider', () => { expect(identity!.avatar).toBe('http://avatar'); }); - test('getPermissions surfaces role from user_metadata', async () => { + test('getPermissions fails closed without a trusted resolver', async () => { const { createSupabaseAuthProvider } = await import('./auth-provider'); const auth = createSupabaseAuthProvider(createMockSupabaseClient()); - const role = await auth.getPermissions?.(); - expect(role).toBe('admin'); + const permissions = await auth.getPermissions?.(); + expect(permissions).toBeNull(); + }); + + test('getPermissions uses the configured permission resolver', async () => { + const { createSupabaseAuthProvider } = await import('./auth-provider'); + const client = createMockSupabaseClient(); + const auth = createSupabaseAuthProvider(client, { + getPermissions: ({ user }) => user.email === 'admin@test.com' + ? ['admin', 'posts:edit'] + : [], + }); + const permissions = await auth.getPermissions?.(); + expect(permissions).toEqual(['admin', 'posts:edit']); }); test('getIdentity clears invalid refresh token sessions', async () => { From b9424a6be68d80f38a5738418fa8d2f08b8a8848 Mon Sep 17 00:00:00 2001 From: cnb Date: Wed, 5 Aug 2026 11:15:18 +0800 Subject: [PATCH 2/3] fix(auth): stop treating client claims as permissions --- docs/auth.md | 7 ++++- docs/src/content/docs/hooks/auth.md | 17 +++++++----- docs/src/content/docs/providers/auth.md | 7 ++++- docs/src/content/docs/providers/sso.md | 2 +- docs/src/content/docs/zh-cn/hooks/auth.md | 16 ++++++----- docs/src/content/docs/zh-cn/providers/auth.md | 6 ++++- docs/src/content/docs/zh-cn/providers/sso.md | 2 +- packages/core/src/auth-hooks.svelte.ts | 11 ++++---- packages/core/src/permissions.svelte.ts | 18 +++++++------ ...ons.test.ts => permissions.test.svelte.ts} | 27 +++++++++++++++++-- packages/core/src/types.ts | 5 ++++ packages/sso/src/auth-provider.test.ts | 6 ++++- 12 files changed, 89 insertions(+), 35 deletions(-) rename packages/core/src/{permissions.test.ts => permissions.test.svelte.ts} (87%) diff --git a/docs/auth.md b/docs/auth.md index 96496c6a..e2a605b2 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -10,6 +10,7 @@ interface AuthProvider { logout: (params?: Record) => Promise; check: (params?: Record) => Promise; getIdentity: () => Promise; + // UI-only hints; API, RLS, and action handlers must authorize independently. getPermissions?: (params?: Record) => Promise; register?: (params: Record) => Promise; forgotPassword?: (params: Record) => Promise; @@ -87,10 +88,14 @@ mutate(error); // Calls authProvider.onError → may redirect or logout ```typescript const { raw, has, can, isLoading, error } = usePermissions(); -// raw: whatever trusted authProvider.getPermissions() resolver returns +// raw: whatever trusted authProvider.getPermissions() resolver returns (UI hints only) // has/can: exact client-side UI checks ``` +The built-in Supabase and SSO providers deliberately do not implement `getPermissions()`. +If an application supplies it, its value may change labels, navigation, or disabled controls +only. API, RLS, and action handlers must independently authenticate and authorize every request. + ## Auth Pages Built-in glassmorphism auth pages: diff --git a/docs/src/content/docs/hooks/auth.md b/docs/src/content/docs/hooks/auth.md index 7e3b34ef..38c7ea5b 100644 --- a/docs/src/content/docs/hooks/auth.md +++ b/docs/src/content/docs/hooks/auth.md @@ -39,18 +39,21 @@ const { isAuthenticated, isLoading } = useIsAuthenticated(); `usePermissions()` is a client-side rendering helper. It can hide or disable UI, but APIs, data providers, and database policies must enforce authorization independently. ```typescript -const { raw, has, can, isLoading, refetch } = usePermissions(); +const permissionHints = usePermissions(); -// Check specific permission -if (has('admin')) { /* ... */ } +// Change navigation or a disabled control from a UI hint. +if (permissionHints.has('admin')) { /* ... */ } -// Check resource:action permission -if (can('posts', 'edit')) { /* ... */ } +// Read a UI hint using the resource:action naming convention. +if (permissionHints.can('posts', 'edit')) { /* ... */ } -// Session-level refresh (e.g. after role upgrade) -await refetch(); +await permissionHints.refetch(); ``` +The built-in Supabase and SSO providers leave `getPermissions()` undefined. A custom +implementation can supply UI hints only; do not use these browser-visible values as an API, +RLS, or action authorization decision. The backend must authenticate and authorize every request. + ### `useOnError()` ```typescript diff --git a/docs/src/content/docs/providers/auth.md b/docs/src/content/docs/providers/auth.md index ce3e7027..f658f3ea 100644 --- a/docs/src/content/docs/providers/auth.md +++ b/docs/src/content/docs/providers/auth.md @@ -13,6 +13,7 @@ interface AuthProvider { logout: (params?: Record) => Promise; check: (params?: Record) => Promise; getIdentity: () => Promise; + // UI-only hints; API, RLS, and action handlers must authorize independently. getPermissions?: (params?: Record) => Promise; register?: (params: Record) => Promise; forgotPassword?: (params: Record) => Promise; @@ -35,7 +36,7 @@ interface AuthProvider { | `useGetIdentity()` | Get current user info | | `useIsAuthenticated()` | Check auth status | | `useOnError()` | Handle API errors (401→logout) | -| `usePermissions()` | Get user permissions | +| `usePermissions()` | Get UI-only permission hints | ### Usage @@ -44,6 +45,10 @@ const { mutate: login, isPending } = useLogin(); await login({ email: 'user@example.com', password: 'secret' }); ``` +The built-in Supabase and SSO providers intentionally leave `getPermissions()` undefined. +An application may add it for labels, navigation, or disabled controls, but browser-visible +values never authorize API, RLS, or action requests; the backend must enforce those separately. + ## Auth Pages Built-in glassmorphism auth pages included: diff --git a/docs/src/content/docs/providers/sso.md b/docs/src/content/docs/providers/sso.md index ac91487a..024b6f87 100644 --- a/docs/src/content/docs/providers/sso.md +++ b/docs/src/content/docs/providers/sso.md @@ -118,7 +118,7 @@ const authProvider = createSSOAuthProvider({ ### Trusted Permissions Resolver -`getPermissions()` returns `null` unless you configure a trusted resolver. Use the resolver to call your application authorization endpoint or another backend-controlled source. ID Token claims can be useful display hints, but they are not a replacement for API or database authorization. +`getPermissions()` returns `null` unless you configure a trusted resolver. Use the resolver to call your application authorization endpoint or another backend-controlled source. ID Token claims are not turned into browser permissions; this is a UI hint only. API endpoints must independently validate the token and enforce authorization. ```typescript const authProvider = createSSOAuthProvider({ diff --git a/docs/src/content/docs/zh-cn/hooks/auth.md b/docs/src/content/docs/zh-cn/hooks/auth.md index d62adb3d..705ef10c 100644 --- a/docs/src/content/docs/zh-cn/hooks/auth.md +++ b/docs/src/content/docs/zh-cn/hooks/auth.md @@ -39,18 +39,20 @@ const { isAuthenticated, isLoading } = useIsAuthenticated(); `usePermissions()` 只是客户端渲染辅助。它可以隐藏或禁用 UI,但 API、DataProvider 和数据库策略必须独立执行授权。 ```typescript -const { raw, has, can, isLoading, refetch } = usePermissions(); +const permissionHints = usePermissions(); -// 检查特定权限 -if (has('admin')) { /* ... */ } +// 使用 UI 提示调整导航或禁用控件。 +if (permissionHints.has('admin')) { /* ... */ } -// 检查资源:操作权限 -if (can('posts', 'edit')) { /* ... */ } +// 使用 resource:action 命名读取 UI 提示。 +if (permissionHints.can('posts', 'edit')) { /* ... */ } -// 重新获取权限(比如角色升级后) -await refetch(); +await permissionHints.refetch(); ``` +内置 Supabase 与 SSO Provider 不提供 `getPermissions()`。自定义实现只能返回 UI 提示,绝不能 +将浏览器中的值作为 API、RLS 或动作授权决定;后端必须认证并授权每一个请求。 + ### `useOnError()` ```typescript diff --git a/docs/src/content/docs/zh-cn/providers/auth.md b/docs/src/content/docs/zh-cn/providers/auth.md index f1e5ef5b..88740d2c 100644 --- a/docs/src/content/docs/zh-cn/providers/auth.md +++ b/docs/src/content/docs/zh-cn/providers/auth.md @@ -13,6 +13,7 @@ interface AuthProvider { logout: (params?: Record) => Promise; check: (params?: Record) => Promise; getIdentity: () => Promise; + // 仅限 UI 提示;API、RLS 和动作处理器必须独立授权。 getPermissions?: (params?: Record) => Promise; register?: (params: Record) => Promise; forgotPassword?: (params: Record) => Promise; @@ -35,7 +36,7 @@ interface AuthProvider { | `useGetIdentity()` | 获取当前用户信息 | | `useIsAuthenticated()` | 检查认证状态 | | `useOnError()` | 处理 API 错误(401→登出) | -| `usePermissions()` | 获取用户权限 | +| `usePermissions()` | 获取仅限 UI 的权限提示 | ### 用法 @@ -44,6 +45,9 @@ const { mutate: login, isPending } = useLogin(); await login({ email: 'user@example.com', password: 'secret' }); ``` +内置 Supabase 与 SSO Provider 有意不实现 `getPermissions()`。应用可自行提供它来调整标签、 +导航或禁用控件,但浏览器中的值绝不能授权 API、RLS 或动作请求;后端必须独立强制授权。 + ## 认证页面 内置毛玻璃风格认证页面: diff --git a/docs/src/content/docs/zh-cn/providers/sso.md b/docs/src/content/docs/zh-cn/providers/sso.md index ac893abd..e015a25f 100644 --- a/docs/src/content/docs/zh-cn/providers/sso.md +++ b/docs/src/content/docs/zh-cn/providers/sso.md @@ -102,7 +102,7 @@ const authProvider = createSSOAuthProvider({ ### 可信权限 Resolver -未配置可信 resolver 时,`getPermissions()` 返回 `null`。请在 resolver 中调用应用自己的授权接口或后端控制的数据源。ID Token claims 可以作为展示提示,但不能替代 API 或数据库授权。 +未配置可信 resolver 时,`getPermissions()` 返回 `null`。ID Token claims 不会自动转化为浏览器权限。请在 resolver 中调用应用自己的授权接口或后端控制的数据源。结果仅用于 UI 提示;API 端点必须独立验证令牌并强制授权。 ```typescript const authProvider = createSSOAuthProvider({ diff --git a/packages/core/src/auth-hooks.svelte.ts b/packages/core/src/auth-hooks.svelte.ts index b3a19bcd..07118f3f 100644 --- a/packages/core/src/auth-hooks.svelte.ts +++ b/packages/core/src/auth-hooks.svelte.ts @@ -269,13 +269,14 @@ export function useOnError() { // ─── usePermissions ────────────────────────────────────────── /** - * Fetches permissions from authProvider.getPermissions(). + * Fetches UI-only hints from authProvider.getPermissions(). * Returns a reactive UI helper with convenience methods for permission checks. - * API routes, data providers, and database policies must enforce authorization - * independently; this hook only gates client-side rendering. - * + * The result can change labels, navigation, and disabled controls, but it runs + * in the browser and never authorizes API, RLS, or action requests. Those + * requests must be independently authenticated and authorized by the backend. + * * Supports `refetch()` for session-level permission refresh (e.g., after role change). - * + * * @example * ```svelte *