4.0.0 â€Ē Published 7 months ago

@asaidimu/iam v4.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
7 months ago

@asaidimu/iam

A lightweight, type-safe Identity and Access Management (IAM) system for client-side JavaScript/TypeScript applications.

npm version License: MIT TypeScript GitHub Workflow Status (main) PRs Welcome

📖 Table of Contents


🚀 Overview & Features

@asaidimu/iam is a versatile and robust Identity and Access Management (IAM) library designed specifically for client-side JavaScript and TypeScript applications. It provides a clean abstraction layer that separates authentication from authorization, enabling you to integrate with any identity provider (e.g., Firebase, Auth0, Supabase, custom JWT) while maintaining a consistent and powerful permission system.

This library empowers developers to build applications with sophisticated access control without being coupled to a specific backend authentication service. It emphasizes type safety, performance, and flexibility, ensuring a smooth developer experience and a secure application.

Key Features

  • 🔐 Authentication Abstraction: Define custom IdentityProvider implementations to integrate seamlessly with any authentication backend, keeping your core application logic provider-agnostic.
  • ðŸšĶ Fine-grained Access Control: Implement declarative, rule-based permissions (e.g., "can edit post," "has admin role") that are evaluated against the current identity and optional resource context.
  • ðŸ§Đ Composable Rules: Build complex access rules using logical operators (AND, OR, NOT, XOR, NAND, NOR) to combine simpler rules, allowing for highly flexible authorization policies.
  • ⚡ Performance Optimized: Features built-in memoization for rule evaluation, minimizing redundant computations and ensuring efficient access checks with a configurable cache TTL.
  • 🔄 Cross-tab Synchronization: Optional DataStorePersistence allows session state to be synchronized across multiple browser tabs or instances, providing a consistent user experience.
  • ⏱ïļ Session Management: Configurable session Time-To-Live (TTL) automatically handles session expiration and renewal.
  • 🔍 Type Safety: Fully written in TypeScript, providing strong typing for identities, properties, rules, and resources, enhancing developer productivity and reducing errors.
  • ⚛ïļ React Integration: Includes a dedicated module (@asaidimu/iam/react) with React hooks (useIdentity, usePermissions, useCan, useEvaluate) and components (PermissionGate, RuleGate) for seamless integration into React applications.
  • ðŸŠķ Lightweight & Modular: Designed with minimal external dependencies, keeping the bundle size small and allowing you to use only the parts you need.

ðŸ“Ķ Installation & Setup

Prerequisites

  • Node.js (16.x or higher)
  • Bun (recommended for development) or npm/yarn
  • TypeScript (5.x or higher) for optimal usage
  • Familiarity with modern JavaScript/TypeScript module systems and React (for React integration).

Installation Steps

Install @asaidimu/iam using your preferred package manager:

# Using Bun (recommended)
bun add @asaidimu/iam

# Using npm
npm install @asaidimu/iam

# Using Yarn
yarn add @asaidimu/iam

Configuration

@asaidimu/iam is configured programmatically within your application code. There are no external configuration files required for the library itself. All setup involves defining your IdentityProvider, SessionManager, and IAMRuleSet directly in your JavaScript/TypeScript files.

Verification

After installation, you can quickly verify that the package is correctly installed by trying to import a core module:

// verify.ts
import { createAuthenticator } from '@asaidimu/iam';
console.log('IAM imported successfully!');

// To run this:
// bun install
// bun run --hot verify.ts
// or `npx ts-node verify.ts` if you have ts-node installed

If no errors are reported, the package is ready for use.


📚 Usage Documentation

This section walks you through the core concepts and practical examples of using @asaidimu/iam.

1. Create an Identity Provider

The IdentityProvider is the bridge between your authentication system and @asaidimu/iam. It defines how users authenticate, deauthenticate, retrieve their identity, and how changes in their authentication state are observed.

You can use createAuthenticator for custom integrations.

Using createAuthenticator (for custom auth)

This factory function allows you to define your own authentication and deauthentication logic, returning an Identity object with permissions and properties.

import { createAuthenticator, type Identity } from '@asaidimu/iam';

// Define the shape of your identity properties
interface MyIdentityProps {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
}

// Example: Authenticator for a simple username/password system
const authProvider = createAuthenticator<MyIdentityProps, [string, string]>({
  authenticator: async (username, password) => {
    // Replace with your actual authentication logic (e.g., API call to your backend)
    // This example simulates a network request.
    await new Promise(resolve => setTimeout(resolve, 500)); // Simulate async
    if (username === 'test@example.com' && password === 'password123') {
      // Upon successful authentication, return an Identity object.
      // `permissions` are string identifiers for general access checks.
      // `properties` hold all specific user data.
      return {
        permissions: ['read:posts', 'create:posts', 'edit:own_posts', 'view:dashboard'],
        properties: {
          id: 'user-123',
          name: 'John Doe',
          email: username,
          role: 'editor',
        },
      };
    }
    return null; // Return null if authentication fails
  },
  deauthenticator: async (userProps) => {
    // Replace with your actual deauthentication logic (e.g., API call to logout)
    // This example simulates a network request.
    await new Promise(resolve => setTimeout(resolve, 300)); // Simulate async
    console.log(`Deauthenticating user ${userProps.email}`);
    return true; // Indicate successful deauthentication
  },
});

// Example usage:
// const isAuthenticated = await authProvider.authenticate('test@example.com', 'password123');
// console.log('Authenticated:', isAuthenticated); // true
// const currentIdentity = authProvider.identity();
// console.log('Current identity:', currentIdentity?.properties.name); // John Doe
// await authProvider.deauthenticate();
// console.log('Identity after deauth:', authProvider.identity()); // null

2. Set Up Session Management

The SessionManager handles the lifecycle of the authenticated identity, including expiration and persistence. It acts as the single source of truth for the current session state.

import { createSessionManager, type Identity, type DataStorePersistence } from '@asaidimu/iam';
// Assuming 'authProvider' is created as shown above (e.g., from './my-auth-provider.ts')
// import { authProvider } from './my-auth-provider';

interface MyIdentityProps { /* ... as defined above ... */ }

// Define a simple localStorage persistence adapter.
// This allows the session to persist across browser tabs and even browser restarts.
const localStoragePersistence: DataStorePersistence<Identity<MyIdentityProps, string>> = {
  set: (id, state) => {
    try {
      localStorage.setItem(id, JSON.stringify(state));
      return true;
    } catch (e) {
      console.error('Error persisting state to localStorage:', e);
      return false;
    }
  },
  get: () => {
    try {
      const item = localStorage.getItem('my-app-session'); // Use a consistent key
      return item ? JSON.parse(item) : null;
    } catch (e) {
      console.error('Error retrieving state from localStorage:', e);
      return null;
    }
  },
  // The subscribe method is crucial for cross-tab synchronization.
  // It listens to 'storage' events from other browser tabs.
  subscribe: (id, callback) => {
    const handler = (e: StorageEvent) => {
      if (e.key === id) {
        callback(e.newValue ? JSON.parse(e.newValue) : null);
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  },
  clear: () => {
    try {
      localStorage.removeItem('my-app-session');
      return true;
    } catch (e) {
      console.error('Error clearing state from localStorage:', e);
      return false;
    }
  }
};

const sessionManager = createSessionManager<MyIdentityProps>(authProvider, {
  sessionTTL: 60 * 60 * 1000, // Optional: session will expire after 1 hour (in milliseconds)
  persistence: localStoragePersistence, // Optional: persist session to localStorage for cross-tab sync
  instanceId: 'my-app-session' // Optional: unique ID for this session instance, defaults to 'default-session'
});

// Subscribe to session state changes (e.g., login, logout, expiration)
// This is useful for global UI updates or application-wide state changes.
const unsubscribe = sessionManager.subscribe((identityProps) => {
  if (identityProps) {
    console.log('User logged in or identity updated:', identityProps.email);
  } else {
    console.log('User logged out or session expired.');
  }
});

// Remember to call unsubscribe() when the listener is no longer needed
// (e.g., on application unmount or specific component cleanup).
// unsubscribe();

3. Define Access Control Rules

Rules are the heart of the access control system. They are functions that take an IAMRuleContext (containing the current identity and an optional resource) and return a boolean indicating access. Rules can be combined using logical operators (and, or, not, xor, nand, nor) for complex logic.

import { and, or, not, IAMBooleanOperator, type IAMRuleSet, type IAMRuleContext, type IAMRule, type CompositeRule } from '@asaidimu/iam';

// Assume MyIdentityProps and PostType are defined based on your application's data
interface MyIdentityProps {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  permissions: string[]; // Permissions from identity provider
}

interface PostType {
  id: string;
  authorId: string;
  status: 'draft' | 'published' | 'archived';
  category: 'news' | 'blog' | 'tutorial';
  isFeatured: boolean;
}

// Define your rules using an IAMRuleSet (a Map where keys are permission names)
const rules: IAMRuleSet<MyIdentityProps, PostType> = new Map();

// --- Basic Rules ---

// Rule 1: 'read:posts' - Requires any authenticated identity
rules.set('read:posts', ({ identity }) => !!identity);

// Rule 2: 'create:posts' - Requires 'editor' or 'admin' role
rules.set('create:posts', ({ identity }) =>
  identity?.role === 'editor' || identity?.role === 'admin'
);

// Rule 3: 'edit:own_post' - User can edit their own post
const canEditOwnPost: IAMRule<MyIdentityProps, PostType> = ({ identity, resource }) =>
  !!identity && !!resource && identity.id === resource.authorId;
rules.set('edit:own_post', canEditOwnPost);

// Rule 4: 'edit:any_post' - Admin can edit any post
const isAdmin: IAMRule<MyIdentityProps, PostType> = ({ identity }) =>
  identity?.role === 'admin';
rules.set('edit:any_post', isAdmin);

// Rule 5: 'publish:post' - User can publish a post if they are an editor/admin AND the post is a draft
rules.set('publish:post',
  and(
    or(isAdmin, ({ identity }) => identity?.role === 'editor'), // Must be admin OR editor
    ({ resource }) => resource?.status === 'draft' // And the post must be a draft
  )
);

// --- More Complex Composite Rules ---

// Rule 6: 'delete:private_data' - Requires admin role AND is not a public resource
interface SecureResource {
  isPublic?: boolean;
}
rules.set('delete:private_data',
  and(
    isAdmin,
    not(({ resource }: IAMRuleContext<MyIdentityProps, SecureResource>) => resource?.isPublic === true) // Resource is NOT public
  )
);

// Rule 7: 'access:admin_features' - User must be admin OR an editor AND verified
rules.set('access:admin_features',
  or(
    isAdmin,
    and(
      ({ identity }) => identity?.role === 'editor',
      ({ identity }) => (identity as MyIdentityProps)?.email.endsWith('@verified.com') // Example of a custom property check
    )
  )
);

// Rule 8: 'moderate:comments' - XOR operator example: Either admin OR editor, but NOT both (unlikely, but demonstrates XOR)
rules.set('moderate:comments',
  {
    operator: 'XOR', // Using object literal for composite rule
    rules: [
      isAdmin,
      ({ identity }) => identity?.role === 'editor'
    ]
  }
);

4. Create and Use Access Controller

The AccessController uses the SessionManager and your defined IAMRuleSet to perform access checks. It's the primary interface for authorization logic in your application.

import { createAccessController } from '@asaidimu/iam';
// Assuming 'sessionManager' and 'rules' are created as shown above
// import { sessionManager } from './session-setup';
// import { rules } from './rules-definition';

// Define the types used in your rules
interface MyIdentityProps { /* ... as defined above ... */ }
interface PostType { /* ... as defined above ... */ }

const access = createAccessController<MyIdentityProps, PostType>(sessionManager, rules, {
  defaultAllow: false, // Optional: If a rule is not found, default to deny (false). Default is false.
                        // Set to `true` to allow access if no matching rule is defined.
  cacheTTL: 10000 // Optional: Rule evaluation results cached for 10 seconds (default 5000ms)
});

// Example Usage:

// Check if user has specific permissions (requires explicit permission in identity.permissions array)
// Use 'has' when you expect the permission string to be present in the authenticated identity's
// `permissions` array, usually derived from roles or scope claims.
if (access.has('read:posts')) {
  console.log('User can read posts (permission string found).');
} else {
  console.log('User cannot read posts (permission string not found).');
}

// Check if user has ALL specified permissions
if (access.has(['read:posts', 'create:posts'])) {
  console.log('User can read AND create posts (both permission strings found).');
}

// Check if a permission is granted based on its rule, irrespective of identity.permissions array
// Use 'is' when you want to evaluate a rule defined in your IAMRuleSet directly.
// The rule itself determines access, not just the presence of a string in `identity.permissions`.
if (access.is('create:posts')) {
  console.log('User role allows creating posts (rule evaluated to true).');
}

// Check if user can perform an action on a specific resource
// 'can' combines an implicit permission check (like 'has') with a rule evaluation that can use `resource` context.
const myPost: PostType = { id: 'post-123', authorId: 'user-123', status: 'draft', category: 'blog', isFeatured: false }; // Assume user-123 is the current identity ID
const anotherUserPost: PostType = { id: 'post-456', authorId: 'user-other', status: 'published', category: 'news', isFeatured: true };

if (access.can('edit:own_post', myPost)) {
  console.log('User can edit their own post.');
} else {
  console.log('User cannot edit their own post (or post is not theirs).');
}

if (access.can('publish:post', myPost)) {
  console.log('User can publish this draft post.');
} else {
  console.log('User cannot publish this post.');
}

// Evaluate a custom rule directly (useful for ad-hoc checks or rules not mapped to a permission string)
// 'evaluate' is the most flexible, allowing you to pass any rule or composite rule directly.
const isManagerRule = ({ identity }: IAMRuleContext<MyIdentityProps>) => identity?.role === 'admin' || identity?.role === 'editor';
if (access.evaluate(isManagerRule)) {
  console.log('Current user is a manager or admin.');
}

// Example with a resource-specific ad-hoc rule
const isFeaturedTutorial = ({ resource }: IAMRuleContext<any, PostType>) =>
  resource?.isFeatured && resource.category === 'tutorial';

if (access.evaluate(isFeaturedTutorial, anotherUserPost)) {
  console.log('The other user\'s post is a featured tutorial.');
} else {
  console.log('The other user\'s post is not a featured tutorial.');
}

Advanced Usage

Dynamic Rule Creation

You can create functions that generate rules, making your rule definitions more reusable and dynamic, especially useful for checks like resource ownership based on different property names.

// Create a rule factory for resource ownership checks
// T is IdentityProps, R is ResourceType
function isOwner<T, R extends { [key: string]: any }>(resourceKey: keyof R): IAMRule<T, R> {
  return ({ identity, resource }) => {
    if (!identity || !resource || !resource[resourceKey]) return false;
    // Assuming identity.id is the user's unique identifier
    return identity.id === resource[resourceKey];
  };
}

// Use with various resources and their respective owner ID properties
rules.set('edit:document', isOwner<MyIdentityProps, { id: string; documentOwnerId: string }>('documentOwnerId'));
rules.set('delete:project', isOwner<MyIdentityProps, { id: string; projectOwnerId: string }>('projectOwnerId'));

// Example usage:
// const myDoc = { id: 'doc-1', documentOwnerId: 'user-123' };
// access.can('edit:document', myDoc); // true if current user is 'user-123'

Role-Based Access Control (RBAC)

Implement hierarchical RBAC by defining a role hierarchy and a utility rule to check roles and their inherited permissions.

// Define a simple role hierarchy: a role grants permissions of roles below it.
const roleHierarchy: Record<MyIdentityProps['role'], MyIdentityProps['role'][]> = {
  admin: ['editor', 'viewer'],
  editor: ['viewer'],
  viewer: [],
  user: ['viewer'], // Default role
  guest: [] // No special inherited roles for guest
};

// Function to check if identity has a specific role or inherits it
function hasRole<T>(requiredRole: MyIdentityProps['role']): IAMRule<T> {
  return ({ identity }) => {
    if (!identity || !identity.role) return false;

    // Check if the current user's role directly matches the required role
    if (identity.role === requiredRole) return true;

    // Check if the current user's role implicitly grants the required role
    const grantedRoles = roleHierarchy[identity.role];
    return grantedRoles ? grantedRoles.includes(requiredRole) : false;
  };
}

// Use in rules for role-based permissions
rules.set('manage:users', hasRole('admin'));
rules.set('approve:content', or(hasRole('admin'), hasRole('editor'))); // Admins or Editors can approve
rules.set('view:dashboard', hasRole('viewer')); // Even simplest roles can be enforced

Custom Rule Composition

Create custom functions for composing rules beyond and, or, not to fit specific business logic patterns.

// Create a rule that requires all specified permissions to be in the identity's permissions array
// This is a direct check against the `identity.permissions` field.
function requireAllIdentityPermissions<T>(...requiredPerms: string[]): IAMRule<T> {
  return ({ identity }) => {
    if (!identity || !identity.permissions) return false;
    return requiredPerms.every(perm => identity.permissions.includes(perm));
  };
}

// Create a rule that requires any of the specified permissions to be in the identity's permissions array
function requireAnyIdentityPermission<T>(...requiredPerms: string[]): IAMRule<T> {
  return ({ identity }) => {
    if (!identity || !identity.permissions) return false;
    return requiredPerms.some(perm => identity.permissions.includes(perm));
  };
}

// Use in rules
rules.set('full:content_management', requireAllIdentityPermissions<MyIdentityProps>('create:content', 'edit:content', 'delete:content'));
rules.set('access:reports', requireAnyIdentityPermission<MyIdentityProps>('view:analytics', 'download:reports'));

⚛ïļ Integration with React

@asaidimu/iam provides a dedicated React integration module (@asaidimu/iam/react) with hooks and components to simplify building dynamic UIs based on authentication and authorization state.

Setting up IAM with React

It's recommended to create a single file (e.g., src/iam-setup.ts or src/hooks/useIAM.ts) to initialize and export your IAM instances and hooks, making them easily accessible throughout your React application.

// src/iam-setup.ts
import {
  createAuthenticator,
  createSessionManager,
  createAccessController,
  and, or, not,
  type IAMRuleSet,
  type Identity,
  type DataStorePersistence
} from '@asaidimu/iam';
import { createIAMHooks } from '@asaidimu/iam/react';

// --- 1. Define Identity and Resource Types ---
// These types should match the actual data your application handles.
interface AppUserIdentity {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'user' | 'guest';
  permissions: string[]; // Actual permissions/scopes from backend
}

interface AppResource {
  id: string;
  authorId?: string;
  status?: 'draft' | 'published' | 'archived';
  isPublic?: boolean;
  category?: string;
}

// --- 2. Initialize Identity Provider (Choose one option based on your auth solution) ---

const authProvider = createAuthenticator<AppUserIdentity, [string, string]>({
  authenticator: async (email, password) => {
    const response = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ email, password }) });
    const data = await response.json();
    if (response.ok && data.user) {
      return {
        permissions: data.user.permissions || [],
        properties: { id: data.user.id, email: data.user.email, role: data.user.role }
      };
    }
    return null;
  },
  deauthenticator: async (userProps) => {
    const response = await fetch('/api/logout', { method: 'POST', body: JSON.stringify({ userId: userProps.id }) });
    return response.ok;
  }
});


// --- 3. Set Up Session Manager ---
// This persistence adapter uses localStorage to store the session data.
const localStoragePersistence: DataStorePersistence<Identity<AppUserIdentity, string>> = {
  set: (id, state) => {
    localStorage.setItem(id, JSON.stringify(state));
    return true;
  },
  get: () => {
    const item = localStorage.getItem('my-app-session-iam'); // Use a consistent, unique key
    return item ? JSON.parse(item) : null;
  },
  subscribe: (id, callback) => {
    const handler = (e: StorageEvent) => {
      if (e.key === id) {
        callback(e.newValue ? JSON.parse(e.newValue) : null);
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  },
  clear: () => {
    localStorage.removeItem('my-app-session-iam');
    return true;
  }
};

const sessionManager = createSessionManager<AppUserIdentity>(authProvider, {
  sessionTTL: 8 * 60 * 60 * 1000, // Session active for 8 hours (in milliseconds)
  persistence: localStoragePersistence, // Persist session in localStorage
  instanceId: 'my-app-session-iam' // Unique ID for this session instance
});

// --- 4. Define Access Control Rules ---
const rules: IAMRuleSet<AppUserIdentity, AppResource> = new Map();

// General authentication check (identity exists)
rules.set('is:authenticated', ({ identity }) => !!identity);

// Role-based checks
rules.set('is:admin', ({ identity }) => identity?.role === 'admin');
rules.set('is:editor', ({ identity }) => identity?.role === 'editor' || identity?.role === 'admin');
rules.set('is:guest', ({ identity }) => identity?.role === 'guest' || !identity); // Guest if role is guest or unauthenticated

// Resource-based checks (example for a Post resource)
rules.set('view:post', ({ identity, resource }) => {
  if (!resource) return false;
  // Anyone can view published posts. Editors/Admins can view drafts. Owners can view anything.
  if (resource.status === 'published' && resource.isPublic) return true;
  if (!identity) return false; // Unauthenticated cannot view private posts

  return identity.role === 'admin' ||
         identity.role === 'editor' ||
         (identity.id === resource.authorId);
});

rules.set('create:post', ({ identity }) => identity?.role === 'admin' || identity?.role === 'editor');

rules.set('edit:post', ({ identity, resource }) => {
  if (!identity || !resource) return false;
  // Admins can edit any post. Editors can edit published posts. Owners can edit their own.
  return identity.role === 'admin' ||
         (identity.role === 'editor' && resource.status === 'published') ||
         (identity.id === resource.authorId);
});

rules.set('delete:post', ({ identity, resource }) => {
  if (!identity || !resource) return false;
  // Only admins can delete posts
  return identity.role === 'admin';
});

// Composite rules example
rules.set('manage:drafts',
  and(
    ({ identity }) => identity?.role === 'editor' || identity?.role === 'admin',
    ({ resource }) => resource?.status === 'draft' // Only manage drafts
  )
);

// --- 5. Create and Export IAM Hooks and Components ---
// This is the primary export for your React application.
export const {
  useIdentity,       // Hook: Get current identity properties
  usePermissions,    // Hook: Check if identity has specific permissions (from identity.permissions array)
  useCan,            // Hook: Check if identity can perform action on resource (rule-based evaluation)
  useEvaluate,       // Hook: Evaluate a custom rule directly (ad-hoc checks)
  PermissionGate,    // Component: Conditionally render children based on `usePermissions`
  RuleGate,          // Component: Conditionally render children based on `useEvaluate`
  withPermission,    // HOC: Wrap components for conditional rendering based on `usePermissions`
  access             // Direct access to the access controller instance for non-hook/component use cases
} = createIAMHooks<AppUserIdentity, AppResource>(sessionManager, rules);

// Also export the session manager and auth provider for direct imperative access if needed
// (e.g., for login/logout buttons in your application).
export { sessionManager, authProvider };

Using IAM Hooks in React Components

Once iam-setup.ts is configured, you can import and use the hooks and components in your React application:

// src/components/HomePage.tsx
import React from 'react';
// Import hooks and components directly from your setup file
import { useIdentity, usePermissions, useCan, PermissionGate, sessionManager, authProvider } from '../iam-setup';

interface PostDataType {
  id: string;
  authorId: string;
  status: 'draft' | 'published';
  title: string;
}

function HomePage() {
  const identity = useIdentity(); // Gets { id, email, role, permissions } or null
  const isLoggedIn = !!identity;
  const currentUserName = identity?.email || 'Guest';

  // Example post data
  const myPost: PostDataType = { id: 'post-101', authorId: identity?.id || 'unknown', status: 'draft', title: 'My Draft Post' };
  const othersPublishedPost: PostDataType = { id: 'post-102', authorId: 'another-user', status: 'published', title: 'A Published Post' };

  // Check permissions with 'usePermissions' (checks `identity.permissions` array)
  const canViewReports = usePermissions('view:reports');

  // Check access to a specific resource with 'useCan' (evaluates the rule configured in iam-setup)
  const canEditMyDraftPost = useCan('edit:post', myPost);
  const canDeleteOthersPublishedPost = useCan('delete:post', othersPublishedPost);


  const handleLogin = async () => {
    // Example: Login using your auth provider
    const success = await authProvider.authenticate('test@example.com', 'password123');
    if (success) {
      console.log('Logged in!');
      // UI will automatically update due to sessionManager.subscribe
    } else {
      console.error('Login failed.');
    }
  };

  const handleLogout = async () => {
    await authProvider.deauthenticate();
    console.log('Logged out!');
  };

  return (
    <div>
      <h1>Welcome, {currentUserName}!</h1>

      {!isLoggedIn && (
        <>
          <p>Please log in to access full features.</p>
          <button onClick={handleLogin}>Log In (Example)</button>
        </>
      )}

      {isLoggedIn && (
        <>
          <button onClick={handleLogout}>Log Out</button>
          <h2>Your Dashboard</h2>
          {canViewReports && <p>You have access to detailed reports.</p>}

          <PermissionGate permission="create:post" fallback={<p>You cannot create posts.</p>}>
            <button>Create New Post</button>
          </PermissionGate>

          {/* Example of using useCan for a resource */}
          {canEditMyDraftPost && (
            <button onClick={() => console.log('Editing post:', myPost.id)}>
              Edit "{myPost.title}"
            </button>
          )}

          {canDeleteOthersPublishedPost && (
            <button onClick={() => console.log('Deleting post:', othersPublishedPost.id)}>
              Delete "{othersPublishedPost.title}" (Admin Only)
            </button>
          )}

          {/* A gate that checks if the user is an admin or editor */}
          <PermissionGate permission={['is:admin', 'is:editor']} fallback={null}>
            <p>Admin/Editor specific content goes here.</p>
          </PermissionGate>
        </>
      )}
    </div>
  );
}

export default HomePage;

Admin Panel Example with RuleGate

The RuleGate component is highly flexible as it directly evaluates any IAMRule or CompositeRule you provide, allowing for very specific contextual checks.

// src/components/AdminPanel.tsx
import React from 'react';
import { PermissionGate, RuleGate, useEvaluate, useCan } from '../iam-setup'; // Import from your setup file
import { and, not, type IAMRuleContext, type IAMRule } from '@asaidimu/iam'; // Import operators for inline rules

// Assume AppUserIdentity is defined as in iam-setup.ts
interface AppUserIdentity {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'user' | 'guest';
  permissions: string[];
}

function AdminPanel() {
  // Example of a custom rule that only allows admins AND if it's not the weekend
  // This rule is defined directly in the component, but could also be in iam-setup.
  const isAdminAndWeekday: IAMRule<AppUserIdentity, unknown> = and(
    ({ identity }) => identity?.role === 'admin',
    () => {
      const day = new Date().getDay();
      return day !== 0 && day !== 6; // Not Sunday (0) or Saturday (6)
    }
  );

  // Use the hook to evaluate this custom rule
  const isAuthorizedForWeekdayOperations = useEvaluate(isAdminAndWeekday);

  // Example of using useCan for a hypothetical sensitive action
  const canAccessSensitiveLogs = useCan('access:sensitive_logs');


  return (
    // Top-level gate: only allow admins to view the admin panel at all
    <PermissionGate
      permission="is:admin"
      fallback={<p>You are not authorized to view the admin panel.</p>}
    >
      <div>
        <h1>Administrator Dashboard</h1>

        <PermissionGate permission="create:user">
          <button>Add New User</button>
        </PermissionGate>

        <PermissionGate permission="delete:post">
          <button>Delete Any Post</button>
        </PermissionGate>

        {canAccessSensitiveLogs && (
          <p>
            <button>View Sensitive System Logs</button>
          </p>
        )}

        {/* A RuleGate that evaluates a custom rule for a specific section */}
        <RuleGate
          rule={({ identity }) => identity?.role === 'admin'} // Simple rule check for specific section
          fallback={<p>Some features are only available to direct administrators.</p>}
        >
          <h2>System Configuration</h2>
          <p>Manage application settings, backups, and critical operations.</p>
        </RuleGate>

        {isAuthorizedForWeekdayOperations && (
          <p className="text-green-600">
            You have full administrative privileges and it's a weekday. Proceed with caution for critical operations!
          </p>
        )}
        {!isAuthorizedForWeekdayOperations && (
            <p className="text-red-600">
                Administrative operations are restricted on weekends or if you're not an admin.
            </p>
        )}
      </div>
    </PermissionGate>
  );
}

export default AdminPanel;

Higher-Order Component (HOC) withPermission

The withPermission HOC is a convenient way to wrap existing components, making them only render if the user has the specified permissions.

// src/components/UserManagementPage.tsx
import React from 'react';
import { withPermission } from '../iam-setup'; // Import from your setup file

interface UserManagementProps {
  // Any props this component might receive, e.g., for filtering
  teamId?: string;
}

function UserManagement({ teamId }: UserManagementProps) {
  return (
    <div>
      <h1>Manage Users {teamId ? `for Team ${teamId}` : ''}</h1>
      <p>This section allows managing user accounts and roles within the application.</p>
      {/* ... User management UI elements ... */}
      <ul>
        <li>Add new users</li>
        <li>Edit user roles</li>
        <li>Deactivate accounts</li>
      </ul>
    </div>
  );
}

// Wrap the component with the HOC to enforce 'manage:users' permission.
// If the user doesn't have the permission, the FallbackComponent will be rendered instead.
export default withPermission(
  'manage:users', // The permission string(s) required
  UserManagement, // The component to render if authorized
  // Optional: Fallback component to render if permission is denied (can be null for nothing)
  () => <p>You do not have the necessary permissions to manage users.</p>
);

🏗ïļ Project Architecture

@asaidimu/iam is designed with a clear separation of concerns, making it modular, extensible, and easy to understand.

Core Components

  • IdentityProvider<IdentityProps>: An interface that defines how an authentication source provides user identity. It's responsible for authenticate, deauthenticate, identity retrieval, refresh, and onChange subscriptions. This is the primary extension point for integrating with any authentication service (e.g., Firebase, Auth0, custom API, Supabase).
  • SessionManager<IdentityProps>: Built on top of an IdentityProvider, it handles the overall user session. This includes managing session expiration (sessionTTL), persisting the identity across sessions (DataStorePersistence), and notifying subscribers of session changes. It provides a stable view of the current authenticated user's properties.
  • AccessController<IdentityProps, ResourceType>: This component consumes the current identity from the SessionManager and applies a set of predefined IAMRules (from an IAMRuleSet) to determine access. It provides high-level methods like has, is, can, and evaluate for performing authorization checks.
  • IAMRule & IAMRuleSet: The fundamental building blocks for authorization. An IAMRule is a function that takes an IAMRuleContext (containing identity properties and an optional resource) and returns a boolean. An IAMRuleSet is a Map that stores these rules, keyed by permission names.
  • CompositeRule: A structure for combining multiple IAMRules with logical operators (AND, OR, NOT, XOR, NAND, NOR), allowing for complex and nested authorization logic.
  • DataStorePersistence<T>: An interface allowing you to plug in any storage mechanism (e.g., localStorage, IndexedDB, cookies) for persisting session state, enabling features like "remember me" or cross-tab synchronization.
  • IAMError: A custom error class for consistent error handling within the IAM system.
  • React Integration (react.tsx): A separate module providing higher-level abstractions (hooks and components) for React applications, simplifying the process of building permission-aware UIs.

Data Flow

  1. Authentication: An IdentityProvider (e.g., createAuthenticator) authenticates a user based on credentials or existing session and returns an Identity object (containing permissions array and properties object). It also emits onChange events when the identity state changes.
  2. Session Management: The SessionManager subscribes to the IdentityProvider's onChange events. When an Identity is received, it stores it (optionally using DataStorePersistence), sets an expiration (sessionTTL), and then notifies its own subscribers about the change to the identity's properties. It also monitors DataStorePersistence for external changes (e.g., from other tabs).
  3. Access Control: The AccessController depends on the SessionManager to retrieve the current identity whenever an access check is requested (has, can, evaluate). It then looks up the relevant IAMRule from its IAMRuleSet and evaluates it using the provided IAMRuleContext (identity properties, optional resource). Rule evaluations are memoized for performance.
  4. UI Rendering (React): In React applications, createIAMHooks provides hooks that use useSyncExternalStore to subscribe to SessionManager changes. This ensures that React components automatically re-render when the identity or permission state changes, allowing for dynamic UI adjustments (e.g., showing/hiding buttons, rendering different content).

Extension Points

  • Custom IdentityProvider: Implement the IdentityProvider interface (or use createAuthenticator) to support any custom authentication flow or third-party authentication service
  • Custom DataStorePersistence: Implement the DataStorePersistence interface to use any storage backend for session state (e.g., sessionStorage, a custom in-memory store for testing, or integration with a global state management library).
  • Custom IAMRules: Rules are simple functions, offering infinite flexibility to define complex authorization logic tailored to your application's specific business requirements. You can define rules that check identity properties, resource properties, external conditions, or combinations thereof.
  • Custom CompositeRule Operators: While the library provides standard boolean operators, the CompositeRule interface is extensible, allowing for more complex logical compositions if needed.

🛠ïļ Development & Contributing

Contributions are welcome! Follow these guidelines to set up your development environment and contribute to @asaidimu/iam.

Development Setup

  1. Clone the repository:
    git clone https://github.com/asaidimu/iam.git
    cd iam
  2. Install dependencies: This project uses Bun for package management and scripting, which is recommended for development.
    bun install
    If you prefer npm/yarn, ensure you have a compatible version of Node.js (v16+) installed, then:
    npm install
    # or
    yarn install

Available Scripts

The following scripts are defined in package.json and can be run using bun run <script-name> or npm run <script-name>:

  • bun ci: Installs dependencies, typically used in CI environments to ensure a clean install.
  • bun clean: Removes the dist directory, which contains compiled output.
  • bun prebuild: Executes before the build script. It first runs bun clean and then executes a TypeScript synchronization script (.sync-package.ts) which likely prepares dist.package.json for publishing.
  • bun build: Compiles TypeScript source files from src to the dist directory. It generates CommonJS (CJS) and ES Module (ESM) formats, along with TypeScript declaration files (.d.ts). This is the primary command to compile the library.
  • bun postbuild: Executes after the build script. It copies essential files (README.md, LICENSE.md, dist.package.json) into the dist directory, ensuring they are included in the published package.

Testing

While a dedicated test script might not be explicitly listed (it might be handled by semantic-release's CI flow), a typical TypeScript project would use a testing framework. You can likely run tests using a standard command:

  • To run tests:
    bun test
    # or (if configured)
    npm test
  • Code Coverage: (If configured with a testing tool like Vitest or Jest)
    bun test --coverage

Contributing Guidelines

We welcome bug reports, feature requests, and pull requests!

  1. Fork the repository and create your branch from main.
  2. Ensure existing tests pass and add new tests for any new features or bug fixes.
  3. Adhere to the coding style (ESLint and Prettier are configured in the project for consistent code formatting).
  4. Write clear, concise commit messages following the Conventional Commits specification (e.g., feat: add new feature, fix: resolve bug, chore: update dependencies). This project uses semantic-release for automated versioning and changelog generation, which relies on these commit messages.
  5. Open a Pull Request with a descriptive title and a detailed explanation of your changes. Link to any relevant issues.

Issue Reporting

Found a bug or have a feature idea? Please open an issue on our GitHub Issues page. Provide as much detail as possible, including steps to reproduce bugs, expected vs. actual behavior, and your environment. https://github.com/asaidimu/iam/issues


â„đïļ Additional Information

Troubleshooting

  • Identity not updating:
    • Verify your IdentityProvider's onChange listener is correctly notifying its subscribers with the new identity state.
    • Ensure your SessionManager is correctly subscribed to the IdentityProvider's changes.
    • If using persistence, check that DataStorePersistence.set is saving the state and DataStorePersistence.subscribe is correctly listening for storage events across tabs.
  • Rules not firing/incorrect results:
    • Confirm your IAMRuleContext (identity, resource) is correctly populated and passed to the AccessController methods (has, can, evaluate).
    • Check for typos in permission names (rules.get('your:permission')).
    • Ensure your rule logic handles null or undefined identity/resource properties gracefully (e.g., using optional chaining ?.).
    • If you're dynamically changing rules at runtime and observing stale results, note that AccessController uses memoization. Re-creating the AccessController instance would clear its internal cache.
  • Persistence issues:
    • Double-check your DataStorePersistence implementation, especially set, get, subscribe, and clear methods for correct storage key usage and JSON.stringify/JSON.parse for serialization/deserialization.
    • Ensure the instanceId passed to createSessionManager is consistent across tabs if you expect cross-tab synchronization.
  • React component not re-rendering:
    • Confirm that useSyncExternalStore in createIAMHooks is correctly wired to sessionManager.subscribe and that its getter functions (the second argument) accurately reflect the state that should trigger a re-render.
    • Ensure that the sessionManager.subscribe callback is indeed being fired when the underlying identity changes.

Changelog & Roadmap

For a detailed history of changes, new features, and breaking changes, please refer to the CHANGELOG.md file. Future plans for @asaidimu/iam may include:

  • More built-in IdentityProvider integrations for popular authentication services (e.g., Firebase, Auth0 directly).
  • Advanced rule debugging and tracing tools for complex authorization policies.
  • Further optimizations for performance, potentially with Web Workers for heavy rule sets.
  • Enhanced server-side rendering (SSR) support for React hooks and data hydration.
  • More comprehensive documentation and recipes for common authorization patterns.

License

This project is licensed under the MIT License. See the LICENSE.md file for the full license text.

Acknowledgments

Developed and maintained by Saidimu. Special thanks to the open-source community for the tools and inspiration that make projects like this possible, particularly the TypeScript, React, and Bun communities.

4.0.0

7 months ago

3.0.0

8 months ago

2.0.3

10 months ago

2.0.2

10 months ago

2.0.1

10 months ago

2.0.0

10 months ago

1.0.5

10 months ago

1.0.4

10 months ago

1.0.3

1 year ago

1.0.2

1 year ago

1.0.1

1 year ago

1.0.0

1 year ago