Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<OAuthProvidersResult>;
startOAuthLogin(input: StartOAuthLoginInput): Promise<StartOAuthLoginResult>;
finishOAuthLogin(input: FinishOAuthLoginInput): Promise<void>;
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +37,7 @@ export interface AuthContextType {
refreshSession: () => Promise<void>;
isAuthenticated: boolean;
hasRole: (role: string) => boolean | undefined;
hasScopedRole: (role: string | string[]) => boolean | undefined;
apiHost: string;
markSignedIn: () => void;
hasSignedInBefore: boolean;
Expand Down Expand Up @@ -169,6 +171,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
};

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);
Expand Down Expand Up @@ -325,6 +329,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
deleteUser,
isAuthenticated,
hasRole,
hasScopedRole,
apiHost,
markSignedIn,
hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false,
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,7 +56,9 @@ export {
createSeamlessAuthClient,
encodePrfSalt,
extractPasskeyPrfResult,
hasScopedRole,
isPasskeyPrfSupported,
roleGrantsAccess,
useAuth,
useAuthClient,
usePasskeySupport,
Expand Down
63 changes: 63 additions & 0 deletions src/scopedRoles.ts
Original file line number Diff line number Diff line change
@@ -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))
);
}
41 changes: 40 additions & 1 deletion tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const Consumer = () => {
<span data-testid="user">{auth.user ? auth.user.email : 'none'}</span>
<span data-testid="isAuthenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="hasRoleAdmin">{String(auth.hasRole('admin'))}</span>
<span data-testid="hasScopedRoleAdminRead">
{String(auth.hasScopedRole('admin:read'))}
</span>
<span data-testid="stepUpFresh">{String(auth.stepUpStatus?.fresh ?? false)}</span>
<span data-testid="credentials">
{auth.credentials.map(credential => credential.friendlyName).join(',')}
Expand Down Expand Up @@ -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);
Expand All @@ -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(
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
);
});

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 () => {
Expand Down
31 changes: 31 additions & 0 deletions tests/scopedRoles.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading