Files
ISA-Frontend/libs/core/auth/README.md
Lorenz Hilpert 2e0853c91a Merged PR 2016: feat(core/auth): add type-safe role-based authorization library
feat(core/auth): add type-safe role-based authorization library

Created @isa/core/auth library with comprehensive role checking:
- RoleService for programmatic hasRole() checks
- IfRoleDirective for declarative *ifRole/*ifNotRole templates
- Type-safe Role enum (CallCenter, Store)
- TokenProvider abstraction with OAuth2 integration
- Signal-based reactive rendering with Angular effects
- Zero-configuration setup via InjectionToken factory

Fixed Bug #5451:
- Hide action buttons for HSC (CallCenter) users on reward order confirmation
- Applied *ifNotRole="Role.CallCenter" to actions container
- Actions now hidden while maintaining card visibility

Testing:
- 18/18 unit tests passing with Vitest
- JUnit and Cobertura reporting configured
- Complete test coverage for role checking logic

Documentation:
- Comprehensive README (817 lines) with API reference
- Usage examples and architecture diagrams
- Updated library-reference.md (62→63 libraries)

Technical:
- Handles both string and array JWT role formats
- Integrated with @isa/core/logging
- Standalone directive (no module imports)
- Full TypeScript type safety

Closes #5451

Related work items: #5451
2025-11-10 17:00:39 +00:00

21 KiB

@isa/core/auth

Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.

Table of Contents

Overview

@isa/core/auth provides a lightweight, type-safe system for managing role-based authorization in Angular applications. Built with modern Angular patterns (signals, standalone components), it integrates seamlessly with OAuth2 authentication flows.

The Problem It Solves

Traditional role-checking often involves:

  • String literals scattered throughout templates and components
  • No compile-time safety for role names
  • Manual token parsing and claim extraction
  • Repetitive conditional rendering logic

This library provides:

  • Type-safe Role enum with autocomplete
  • Automatic JWT token parsing via OAuthService
  • Declarative role-based rendering with *ifRole directive
  • Reactive updates using Angular signals
  • Centralized role management

Features

  • 🔐 Type-Safe Roles - Enum-based role definitions prevent typos
  • 🎯 Declarative Templates - *ifRole and *ifNotRole structural directives
  • Signal-Based - Reactive role checking with Angular signals
  • 🔄 Flexible Token Provider - Injectable abstraction with OAuth2 default
  • 📝 Comprehensive Logging - Integrated with @isa/core/logging
  • 🧪 Fully Tested - 18 unit tests with Vitest
  • 🎨 Standalone - No module imports required

Quick Start

1. Import the directive and Role enum:

import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';

@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [IfRoleDirective],
  template: `
    <!-- Show content only for Store users -->
    <div *ifRole="Role.Store">
      <h2>Store Dashboard</h2>
      <!-- Store-specific features -->
    </div>

    <!-- Show content only for CallCenter users -->
    <div *ifRole="Role.CallCenter">
      <h2>CallCenter Dashboard</h2>
      <!-- CallCenter-specific features -->
    </div>

    <!-- Hide content from CallCenter users -->
    <div *ifNotRole="Role.CallCenter">
      <button>Complete Order</button>
    </div>
  `
})
export class DashboardComponent {
  protected readonly Role = Role; // Expose to template
}

2. Use RoleService programmatically:

import { Component, inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';

@Component({
  selector: 'app-nav',
  template: `...`
})
export class NavComponent {
  private readonly roleService = inject(RoleService);

  ngOnInit() {
    if (this.roleService.hasRole(Role.Store)) {
      // Enable store-specific navigation
    }
  }
}

3. No configuration needed! The library automatically uses OAuthService to parse JWT tokens.

Core Concepts

Role Enum

Roles are defined as a const object with TypeScript type safety:

export const Role = {
  CallCenter: 'CallCenter',  // HSC (Hugendubel Service Center)
  Store: 'Store',            // Store/Branch users
} as const;

export type Role = (typeof Role)[keyof typeof Role];

Benefits:

  • Autocomplete in IDEs
  • Compile-time checking prevents invalid roles
  • Easy to extend with new roles

Token Provider Pattern

The library uses an injectable TokenProvider abstraction to decouple from specific authentication implementations:

export interface TokenProvider {
  getClaimByKey(key: string): unknown;
}

Default Implementation:

  • Automatically provided via InjectionToken factory
  • Uses OAuthService.getAccessToken() to fetch JWT
  • Parses token using parseJwt() utility
  • No manual configuration required

Signal-Based Reactivity

The IfRoleDirective uses Angular effects for automatic re-rendering when roles change:

constructor() {
  effect(() => {
    this.render(); // Re-render when ifRole/ifNotRole inputs change
  });
}

API Reference

Role (Enum)

Type-safe role definitions for the application.

export const Role = {
  CallCenter: 'CallCenter',  // HSC users
  Store: 'Store',            // Store users
} as const;

Usage:

import { Role } from '@isa/core/auth';

if (roleService.hasRole(Role.Store)) {
  // Type-safe!
}

RoleService

Service for programmatic role checks.

Methods

hasRole(role: Role | Role[]): boolean

Check if the authenticated user has specific role(s).

Parameters:

  • role - Single role or array of roles to check (AND logic for arrays)

Returns: true if user has all specified roles, false otherwise

Examples:

import { inject } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';

export class ExampleComponent {
  private readonly roleService = inject(RoleService);

  checkAccess() {
    // Single role check
    if (this.roleService.hasRole(Role.Store)) {
      console.log('User is a store employee');
    }

    // Multiple roles (AND logic)
    if (this.roleService.hasRole([Role.Store, Role.CallCenter])) {
      console.log('User has BOTH store AND call center access');
    }

    // Multiple checks
    const isStore = this.roleService.hasRole(Role.Store);
    const isCallCenter = this.roleService.hasRole(Role.CallCenter);

    if (isStore || isCallCenter) {
      console.log('User has at least one role (OR logic)');
    }
  }
}

Logging:

The service logs all role checks at debug level:

[RoleService] Role check: Store => true
[RoleService] Role check: Store, CallCenter => false

IfRoleDirective

Structural directive for declarative role-based rendering.

Selector: [ifRole], [ifRoleElse], [ifNotRole], [ifNotRoleElse]

Inputs

Input Type Description
ifRole Role | Role[] Role(s) required to show template
ifRoleElse TemplateRef Alternative template if user lacks role
ifNotRole Role | Role[] Role(s) that should NOT be present
ifNotRoleElse TemplateRef Alternative template if user has role

Examples

Basic Usage:

<!-- Show for Store users -->
<div *ifRole="Role.Store">
  Store-specific content
</div>

<!-- Show for CallCenter users -->
<div *ifRole="Role.CallCenter">
  CallCenter-specific content
</div>

With Else Template:

<div *ifRole="Role.Store; else noAccess">
  <button>Complete Order</button>
</div>

<ng-template #noAccess>
  <p>You don't have permission to complete orders</p>
</ng-template>

Negation (ifNotRole):

<!-- Hide from CallCenter users -->
<div *ifNotRole="Role.CallCenter">
  <button>Release Reward</button>
  <button>Mark Not Found</button>
  <button>Cancel</button>
</div>

Multiple Roles (AND logic):

<!-- Only show if user has BOTH roles -->
<div *ifRole="[Role.Store, Role.CallCenter]">
  Advanced features requiring both roles
</div>

Component Integration:

import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';

@Component({
  selector: 'app-actions',
  standalone: true,
  imports: [IfRoleDirective],
  template: `
    <div *ifNotRole="Role.CallCenter">
      <button (click)="completeOrder()">Complete</button>
    </div>
  `
})
export class ActionsComponent {
  // Expose Role to template
  protected readonly Role = Role;

  completeOrder() {
    // ...
  }
}

TokenProvider

Injectable abstraction for JWT token parsing.

export interface TokenProvider {
  getClaimByKey(key: string): unknown;
}

Default Implementation:

Automatically provided via InjectionToken factory:

export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
  'TOKEN_PROVIDER',
  {
    providedIn: 'root',
    factory: () => {
      const oAuthService = inject(OAuthService);
      return {
        getClaimByKey: (key: string) => {
          const claims = parseJwt(oAuthService.getAccessToken());
          return claims?.[key] ?? null;
        },
      };
    },
  },
);

Custom Provider (Advanced):

Override the default implementation:

import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';

providers: [
  {
    provide: TOKEN_PROVIDER,
    useValue: {
      getClaimByKey: (key: string) => {
        // Custom token parsing logic
        return myCustomAuthService.getClaim(key);
      }
    } as TokenProvider
  }
]

parseJwt()

Utility function to parse JWT tokens.

export function parseJwt(
  token: string | null
): Record<string, unknown> | null

Parameters:

  • token - JWT token string or null

Returns: Parsed claims object or null

Example:

import { parseJwt } from '@isa/core/auth';

const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const claims = parseJwt(token);

console.log(claims?.['role']); // ['Store']
console.log(claims?.['sub']);  // User ID

Usage Examples

Example 1: Conditional Navigation

import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { IfRoleDirective, Role } from '@isa/core/auth';

@Component({
  selector: 'app-side-menu',
  standalone: true,
  imports: [RouterLink, IfRoleDirective],
  template: `
    <nav>
      <!-- Store-only navigation -->
      <a *ifRole="Role.Store" routerLink="/inventory">
        Inventory Management
      </a>

      <a *ifRole="Role.Store" routerLink="/store-orders">
        Store Orders
      </a>

      <!-- CallCenter-only navigation -->
      <a *ifRole="Role.CallCenter" routerLink="/customer-service">
        Customer Service
      </a>

      <!-- Show for both roles -->
      <a routerLink="/dashboard">
        Dashboard
      </a>
    </nav>
  `
})
export class SideMenuComponent {
  protected readonly Role = Role;
}

Example 2: Guard with RoleService

import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { RoleService, Role } from '@isa/core/auth';

export const storeGuard: CanActivateFn = () => {
  const roleService = inject(RoleService);
  const router = inject(Router);

  if (roleService.hasRole(Role.Store)) {
    return true;
  }

  // Redirect to unauthorized page
  return router.createUrlTree(['/unauthorized']);
};

// Route configuration
export const routes = [
  {
    path: 'inventory',
    component: InventoryComponent,
    canActivate: [storeGuard]
  }
];

Example 3: Computed Signals with Roles

import { Component, inject, computed } from '@angular/core';
import { RoleService, Role } from '@isa/core/auth';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-dashboard',
  template: `
    @if (canManageInventory()) {
      <button (click)="openInventory()">Manage Inventory</button>
    }

    @if (canProcessReturns()) {
      <button (click)="openReturns()">Process Returns</button>
    }
  `
})
export class DashboardComponent {
  private readonly roleService = inject(RoleService);

  // Computed permissions
  canManageInventory = computed(() =>
    this.roleService.hasRole(Role.Store)
  );

  canProcessReturns = computed(() =>
    this.roleService.hasRole([Role.Store, Role.CallCenter])
  );

  openInventory() { /* ... */ }
  openReturns() { /* ... */ }
}

Example 4: Real-World Component (Reward Order Confirmation)

import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';
import { ButtonComponent } from '@isa/ui/buttons';

@Component({
  selector: 'checkout-confirmation-actions',
  standalone: true,
  imports: [IfRoleDirective, ButtonComponent],
  template: `
    <div class="action-card">
      <div class="message">
        Please complete the order or select an action.
      </div>

      <!-- Hide actions from CallCenter (HSC) users -->
      <div *ifNotRole="Role.CallCenter" class="actions">
        <select [(ngModel)]="selectedAction">
          <option value="collect">Release Reward</option>
          <option value="not-found">Not Found</option>
          <option value="cancel">Cancel</option>
        </select>

        <button uiButton color="primary" (click)="complete()">
          Complete
        </button>
      </div>
    </div>
  `
})
export class ConfirmationActionsComponent {
  protected readonly Role = Role;
  selectedAction = 'collect';

  complete() {
    // Complete order logic
  }
}

Configuration

No configuration needed! The library automatically uses OAuthService:

import { Component } from '@angular/core';
import { IfRoleDirective, Role } from '@isa/core/auth';

@Component({
  standalone: true,
  imports: [IfRoleDirective],
  // Works out of the box!
})
export class MyComponent {}

Custom TokenProvider (Advanced)

Override the default token provider:

import { ApplicationConfig } from '@angular/core';
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: TOKEN_PROVIDER,
      useFactory: () => {
        const customAuth = inject(CustomAuthService);
        return {
          getClaimByKey: (key: string) => customAuth.getClaim(key)
        } as TokenProvider;
      }
    }
  ]
};

JWT Token Structure

The library expects JWT tokens with a role claim:

{
  "sub": "user123",
  "role": ["Store"],
  "exp": 1234567890
}

Supported formats:

  • Single role: "role": "Store"
  • Multiple roles: "role": ["Store", "CallCenter"]

Testing

Run Tests

# Run all tests
npx nx test core-auth

# Run with coverage
npx nx test core-auth --coverage.enabled=true

# Skip cache (fresh run)
npx nx test core-auth --skip-nx-cache

Test Results

✓ src/lib/role.service.spec.ts (11 tests)
✓ src/lib/if-role.directive.spec.ts (7 tests)

Test Files  2 passed (2)
Tests  18 passed (18)

Testing in Your App

Mock RoleService:

import { describe, it, expect, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { RoleService, Role } from '@isa/core/auth';

describe('MyComponent', () => {
  let roleService: RoleService;

  beforeEach(() => {
    roleService = {
      hasRole: vi.fn().mockReturnValue(true)
    } as any;

    TestBed.configureTestingModule({
      providers: [
        { provide: RoleService, useValue: roleService }
      ]
    });
  });

  it('should show store content for store users', () => {
    vi.spyOn(roleService, 'hasRole').mockReturnValue(true);

    const fixture = TestBed.createComponent(MyComponent);
    fixture.detectChanges();

    expect(roleService.hasRole).toHaveBeenCalledWith(Role.Store);
    // Assert UI changes
  });
});

Mock TokenProvider:

import { TOKEN_PROVIDER, TokenProvider, Role } from '@isa/core/auth';

const mockTokenProvider: TokenProvider = {
  getClaimByKey: vi.fn().mockReturnValue([Role.Store])
};

TestBed.configureTestingModule({
  providers: [
    { provide: TOKEN_PROVIDER, useValue: mockTokenProvider }
  ]
});

Architecture

Design Patterns

1. Token Provider Pattern

  • Abstracts JWT parsing behind injectable interface
  • Allows custom implementations without changing consumers
  • Default factory provides OAuthService integration

2. Signal-Based Reactivity

  • Uses Angular signals for reactive role checks
  • Effect-driven template updates
  • Minimal re-renders with fine-grained reactivity

3. Type-Safe Enum Pattern

  • Const object with as const assertion
  • Provides autocomplete and compile-time safety
  • Prevents typos and invalid role strings

Architecture Diagram

┌─────────────────────────────────────────────────────┐
│                  Application Layer                   │
│  ┌─────────────────┐       ┌──────────────────┐    │
│  │  Components     │       │  Route Guards    │    │
│  │  (Templates)    │       │                  │    │
│  └────────┬────────┘       └────────┬─────────┘    │
│           │                         │              │
│           │ *ifRole                 │ hasRole()    │
│           ▼                         ▼              │
├───────────────────────────────────────────────────┤
│              @isa/core/auth Library                │
│  ┌──────────────────┐       ┌─────────────────┐   │
│  │ IfRoleDirective  │       │  RoleService    │   │
│  │  (Signals)       │──────▶│  (Injectable)   │   │
│  └──────────────────┘       └────────┬────────┘   │
│                                      │            │
│                              hasRole(Role[])      │
│                                      │            │
│                                      ▼            │
│                          ┌────────────────────┐   │
│                          │  TokenProvider     │   │
│                          │  (InjectionToken)  │   │
│                          └────────┬───────────┘   │
│                                   │               │
│                         getClaimByKey('role')     │
│                                   │               │
├───────────────────────────────────┼──────────────┤
│                                   ▼               │
│               ┌──────────────────────────┐        │
│               │  OAuthService            │        │
│               │  (angular-oauth2-oidc)   │        │
│               └──────────┬───────────────┘        │
│                          │                        │
│                   getAccessToken()                │
│                          │                        │
└──────────────────────────┼────────────────────────┘
                           ▼
                   ┌───────────────┐
                   │  JWT Token    │
                   │  { role: ... }│
                   └───────────────┘

Role Claim Handling

The library handles both single and multiple role formats:

// Single role (string)
{ "role": "Store" }

// Multiple roles (array)
{ "role": ["Store", "CallCenter"] }

// Internal normalization using coerceArray()
const userRolesArray = coerceArray(userRoles);

Dependencies

External Dependencies

  • @angular/core - Angular framework
  • @angular/cdk/coercion - Array coercion utility
  • angular-oauth2-oidc - OAuth2/OIDC authentication
  • @isa/core/logging - Logging integration

Internal Dependencies

No other ISA libraries required beyond @isa/core/logging.

Import Path

import {
  RoleService,
  IfRoleDirective,
  Role,
  TokenProvider,
  TOKEN_PROVIDER,
  parseJwt
} from '@isa/core/auth';

Path Alias: @isa/core/authlibs/core/auth/src/index.ts


License: ISC Version: 1.0.0 Last Updated: 2025-01-10