From 3e16d8ad0b6b7f938c88e9a15e9706bb02e457f4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 23 May 2026 21:52:47 -0400 Subject: [PATCH] feat: scoped role support --- README.md | 18 +++++++++++ src/AuthProvider.tsx | 5 +++ src/index.ts | 3 ++ src/scopedRoles.ts | 63 +++++++++++++++++++++++++++++++++++++ tests/authProvider.test.tsx | 41 +++++++++++++++++++++++- tests/scopedRoles.test.ts | 31 ++++++++++++++++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 src/scopedRoles.ts create mode 100644 tests/scopedRoles.test.ts diff --git a/README.md b/README.md index fad3cf0..504db10 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - `createSeamlessAuthClient()` - `useAuthClient()` - `usePasskeySupport()` +- `hasScopedRole()` and `roleGrantsAccess()` - types including `AuthMode`, `AuthContextType`, `Credential`, `User`, `OAuthProvider`, `StepUpStatus`, and the headless client input/result types ## Installation @@ -104,6 +105,7 @@ You are still responsible for your app’s route protection and redirects. hasSignedInBefore: boolean; markSignedIn(): void; hasRole(role: string): boolean | undefined; + hasScopedRole(role: string | string[]): boolean | undefined; listOAuthProviders(): Promise; startOAuthLogin(input: StartOAuthLoginInput): Promise; finishOAuthLogin(input: FinishOAuthLoginInput): Promise; @@ -162,6 +164,22 @@ async function completeLogin() { To disable this auto-detection entirely, pass `autoDetectPreviousSignin={false}` to `AuthProvider`. +### Scoped roles + +`hasRole(role)` remains an exact role check. Use `hasScopedRole(role)` for colon-separated scoped +roles such as `admin:read` and `admin:write`. + +```tsx +const { hasRole, hasScopedRole } = useAuth(); + +hasRole('admin'); // exact legacy role check +hasScopedRole('admin:read'); // true for admin, admin:read, or admin:write +hasScopedRole('admin:write'); // true for admin or admin:write +``` + +The package also exports standalone `hasScopedRole(roles, required)` and `roleGrantsAccess(...)` +helpers for code that is not inside `AuthProvider`. + ### Step-up authentication Use step-up authentication before sensitive actions that should require a fresh user verification, such as deleting an account, changing MFA settings, or viewing recovery material. diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index ce02c21..7b0d01d 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -28,6 +28,7 @@ import React, { import { AuthMode } from './fetchWithAuth'; import { usePreviousSignIn } from './hooks/usePreviousSignIn'; +import { hasScopedRole as rolesGrantScopedAccess } from './scopedRoles'; export interface AuthContextType { user: User | null; @@ -36,6 +37,7 @@ export interface AuthContextType { refreshSession: () => Promise; isAuthenticated: boolean; hasRole: (role: string) => boolean | undefined; + hasScopedRole: (role: string | string[]) => boolean | undefined; apiHost: string; markSignedIn: () => void; hasSignedInBefore: boolean; @@ -169,6 +171,8 @@ export const AuthProvider: React.FC = ({ }; const hasRole = (role: string) => user?.roles?.includes(role); + const hasScopedRole = (role: string | string[]) => + user ? rolesGrantScopedAccess(user.roles, role) : undefined; const validateToken = useCallback(async () => { setLoading(true); @@ -325,6 +329,7 @@ export const AuthProvider: React.FC = ({ deleteUser, isAuthenticated, hasRole, + hasScopedRole, apiHost, markSignedIn, hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false, diff --git a/src/index.ts b/src/index.ts index 23fa5f7..821a6a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ import { import { AuthMode } from '@/fetchWithAuth'; import { useAuthClient } from '@/hooks/useAuthClient'; import { usePasskeySupport } from '@/hooks/usePasskeySupport'; +import { hasScopedRole, roleGrantsAccess } from '@/scopedRoles'; import { Credential, Organization, OrganizationMembership, User } from '@/types'; export { @@ -55,7 +56,9 @@ export { createSeamlessAuthClient, encodePrfSalt, extractPasskeyPrfResult, + hasScopedRole, isPasskeyPrfSupported, + roleGrantsAccess, useAuth, useAuthClient, usePasskeySupport, diff --git a/src/scopedRoles.ts b/src/scopedRoles.ts new file mode 100644 index 0000000..7be66e8 --- /dev/null +++ b/src/scopedRoles.ts @@ -0,0 +1,63 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +export function roleGrantsAccess(grantedRole: string, requiredRole: string): boolean { + const granted = grantedRole.trim(); + const required = requiredRole.trim(); + + if (!granted || !required) { + return false; + } + + if (granted === required) { + return true; + } + + if (granted.endsWith(':*')) { + const prefix = granted.slice(0, -2); + return required === prefix || required.startsWith(`${prefix}:`); + } + + if (!required.includes(':')) { + return false; + } + + if (!granted.includes(':')) { + return required.startsWith(`${granted}:`); + } + + const grantedParts = granted.split(':'); + const requiredParts = required.split(':'); + const grantedAction = grantedParts[grantedParts.length - 1]; + const requiredAction = requiredParts[requiredParts.length - 1]; + const grantedPrefix = grantedParts.slice(0, -1); + const requiredPrefix = requiredParts.slice(0, -1); + + return ( + grantedAction === 'write' && + requiredAction === 'read' && + grantedPrefix.length === requiredPrefix.length && + grantedPrefix.every((part, index) => part === requiredPrefix[index]) + ); +} + +export function hasScopedRole( + grantedRoles: unknown, + requiredRoles: string | string[] +): boolean { + if (!Array.isArray(grantedRoles)) { + return false; + } + + const granted = grantedRoles.filter( + (role): role is string => typeof role === 'string' + ); + const required = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles]; + + return required.some(requiredRole => + granted.some(grantedRole => roleGrantsAccess(grantedRole, requiredRole)) + ); +} diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 0ea2878..4bb3a70 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -24,6 +24,9 @@ const Consumer = () => { {auth.user ? auth.user.email : 'none'} {String(auth.isAuthenticated)} {String(auth.hasRole('admin'))} + + {String(auth.hasScopedRole('admin:read'))} + {String(auth.stepUpStatus?.fresh ?? false)} {auth.credentials.map(credential => credential.friendlyName).join(',')} @@ -74,7 +77,12 @@ describe('AuthProvider', () => { mockFetchWithAuthImpl.mockResolvedValueOnce({ ok: true, json: async () => ({ - user: { id: '1', email: 'test@example.com', phone: '555-1234', roles: ['admin'] }, + user: { + id: '1', + email: 'test@example.com', + phone: '555-1234', + roles: ['admin'], + }, credentials: [], }), } as any); @@ -92,6 +100,37 @@ describe('AuthProvider', () => { }); expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('true'); expect(screen.getByTestId('hasRoleAdmin')).toHaveTextContent('true'); + expect(screen.getByTestId('hasScopedRoleAdminRead')).toHaveTextContent('true'); + }); + + it('supports scoped role checks without changing exact role checks', async () => { + mockFetchWithAuthImpl.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: { + id: '1', + email: 'test@example.com', + phone: '555-1234', + roles: ['admin:write'], + }, + credentials: [], + }), + } as any); + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + + expect(screen.getByTestId('hasRoleAdmin')).toHaveTextContent('false'); + expect(screen.getByTestId('hasScopedRoleAdminRead')).toHaveTextContent('true'); }); it('logs out if token validation fails (bad response)', async () => { diff --git a/tests/scopedRoles.test.ts b/tests/scopedRoles.test.ts new file mode 100644 index 0000000..7c4afc7 --- /dev/null +++ b/tests/scopedRoles.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { hasScopedRole, roleGrantsAccess } from '../src/scopedRoles'; + +describe('scoped roles', () => { + it('matches exact roles', () => { + expect(roleGrantsAccess('admin', 'admin')).toBe(true); + expect(roleGrantsAccess('admin:read', 'admin:read')).toBe(true); + }); + + it('lets broad and write roles satisfy scoped read checks', () => { + expect(roleGrantsAccess('admin', 'admin:read')).toBe(true); + expect(roleGrantsAccess('admin:write', 'admin:read')).toBe(true); + }); + + it('does not let read satisfy write or plain broad checks', () => { + expect(roleGrantsAccess('admin:read', 'admin:write')).toBe(false); + expect(roleGrantsAccess('admin:read', 'admin')).toBe(false); + }); + + it('checks any granted role against any required role', () => { + expect(hasScopedRole(['user', 'admin:read'], ['admin:write', 'admin:read'])).toBe( + true + ); + expect(hasScopedRole(['user'], ['admin:write', 'admin:read'])).toBe(false); + }); +});