npm package: @jscotechlabs/auth-sdk
Description: Standardised Authentication & Authorisation SDK for NestJS + Next.js
Latest version: 1.0.1

# @jscotechlabs/auth-sdk

Standardised Authentication & Authorisation SDK for **NestJS** backends and **Next.js** frontends.

Built for multi-tenant enterprise platforms where a **Container App** issues JWTs and child apps (HRMS, CRM, etc.) consume them — without writing a single line of auth logic.

---

## Features

- JWT validation via shared secret — cookie-only, never exposed to JS
- Role-based access control (RBAC) — `@RequireRoles()`
- Permission-based access control — `@RequirePermissions('leave:approve')`
- Authority scope enforcement — `department < company < group`
- ABAC escape hatch — `PolicyGuard(fn)` / `CombinedPolicyGuard(perm, fn)`
- Multi-tenant context — `TenantContextInterceptor` on every request
- React hooks — `useAuth`, `useHasRole`, `useHasPermission`, `useHasScope`, `useCanAccess`
- UI guard components — `<RoleGuard>`, `<PermissionGuard>`, `<ScopeGuard>`, `<ProtectedRoute>`
- Automatic token refresh with concurrent request deduplication

---

## Installation

```bash
npm install @jscotechlabs/auth-sdk
```

### Peer Dependencies

**NestJS apps** must have:

```bash
npm install @nestjs/common @nestjs/core @nestjs/passport @nestjs/jwt passport passport-jwt reflect-metadata rxjs
```

**Next.js apps** must have:

```bash
npm install next react react-dom
```

---

## Architecture

```
Container API  →  issues JWT + sets HttpOnly cookie (.yourdomain.com)
                        │
              ┌─────────┴──────────┐
              ▼                    ▼
         HRMS API              CRM API
    (validates JWT)        (validates JWT)
              │                    │
         HRMS UI               CRM UI
    (reads cookie               (reads cookie
     automatically)              automatically)
```

The JWT is **never passed via URL or Authorization header**. It lives exclusively in an HttpOnly cookie scoped to `.yourdomain.com`. Child apps never issue their own tokens — they validate the Container-issued JWT using a shared secret.

---

## JWT Payload

The Container API issues a JWT with this shape:

```ts
{
  sub:            "employee-uuid",
  email:          "user@company.com",
  companyId:      "company-objectid",
  departmentId:   "dept-objectid",
  designationId:  "desig-objectid",
  roles:          ["HRManager"],
  permissions:    ["leave:approve", "employee:read", "user:create"],
  authorityScope: {
    type:          "company",          // "department" | "company" | "group"
    companyIds:    ["id1", "id2"],
    departmentIds: []
  },
  modules:        ["hrms", "crm"],
  iat:            1234567890,
  exp:            1234568790
}
```

**Permissions format:** `resource:action`
Standard: `create` `read` `update` `delete`
Custom: `approve` `reject` `suspend` `transfer` `export` — anything your domain needs.

---

## Server SDK — NestJS

### 1. Register the module (once in AppModule)

```ts
// app.module.ts
import { AuthSdkServerModule } from "@jscotechlabs/auth-sdk/server";

@Module({
  imports: [
    AuthSdkServerModule.register({
      jwtSecret: process.env.JWT_SECRET, // shared secret — same across all apps
      cookieName: "enterprise_access", // optional, this is the default
      module: "hrms", // this app's module identifier
    }),
  ],
})
export class AppModule {}
```

`AuthGuard` is applied **globally** automatically — all routes are protected by default. Use `@Public()` to opt out.

---

### 2. Protect routes with decorators

```ts
import {
  RequireRoles,
  RequirePermissions,
  RequireScope,
  CurrentUser,
  CurrentScope,
  Public,
} from '@jscotechlabs/auth-sdk/server';
import { AuthUser, AuthorityScope } from '@jscotechlabs/auth-sdk/server';

@Controller('employees')
export class EmployeeController {

  // Public route — no auth required
  @Public()
  @Get('health')
  healthCheck() {
    return { status: 'ok' };
  }

  // Role check — any one role grants access (OR)
  @Get('admin/settings')
  @RequireRoles('CompanyAdmin', 'GroupAdmin')
  getSettings(@CurrentUser() user: AuthUser) {
    return user;
  }

  // Permission check — all permissions required (AND)
  @Get(':id/payslip')
  @RequirePermissions('employee:read', 'payroll:read')
  getPayslip(@CurrentUser('id') userId: string) {
    return { userId };
  }

  // Custom action permission
  @Post(':id/approve-leave')
  @RequirePermissions('leave:approve')
  approveLeave(@CurrentUser() user: AuthUser) { ... }

  // Scope check — company or group level users only
  @Get('company/headcount')
  @RequireScope('company')
  getHeadcount(@CurrentScope() scope: AuthorityScope) {
    return { companyIds: scope.companyIds };
  }

  // Combined — role + permission + scope (all must pass)
  @Post('transfer')
  @RequireRoles('HRManager')
  @RequirePermissions('employee:transfer')
  @RequireScope('company')
  transferEmployee(@CurrentUser() user: AuthUser) { ... }
}
```

---

### 3. ABAC — attribute-based access control

The SDK provides an escape hatch for cases where role/permission checks aren't enough.
Child apps define the policy function — the SDK executes it.

```ts
import { PolicyGuard, CombinedPolicyGuard } from '@jscotechlabs/auth-sdk/server';

@Controller('documents')
export class DocumentController {

  // Pure ABAC — same department can edit
  @Patch(':id')
  @UseGuards(PolicyGuard(async (user, req) => {
    const doc = await DocumentRepo.findById(req.params.id);
    return user.departmentId === doc.departmentId;
  }))
  updateDocument() { ... }

  // RBAC OR ABAC — permission grants access, OR same department
  @Delete(':id')
  @UseGuards(CombinedPolicyGuard(
    'document:delete',                          // RBAC — has permission
    async (user, req) => {                      // ABAC — same department
      const doc = await DocumentRepo.findById(req.params.id);
      return user.departmentId === doc.departmentId;
    },
  ))
  deleteDocument() { ... }
}
```

---

### 4. Tenant context

Every authenticated request automatically gets a `tenantContext` attached:

```ts
import { RequestWithTenant } from '@jscotechlabs/auth-sdk/server';

@Get('data')
getData(@Req() req: RequestWithTenant) {
  const { companyId, departmentId, userId } = req.tenantContext;
  return DataService.findByCompany(companyId);
}
```

---

### Guards reference

| Guard                           | Decorator                       | Logic                                   |
| ------------------------------- | ------------------------------- | --------------------------------------- |
| `AuthGuard`                     | Global (automatic)              | JWT validate — opt out with `@Public()` |
| `RoleGuard`                     | `@RequireRoles(...roles)`       | OR — any one role                       |
| `PermissionGuard`               | `@RequirePermissions(...perms)` | AND — all permissions                   |
| `ScopeGuard`                    | `@RequireScope(level)`          | Hierarchy >= required                   |
| `ModuleAccessGuard`             | Auto on bootstrap               | `modules[]` includes app module         |
| `PolicyGuard(fn)`               | `@UseGuards(PolicyGuard(fn))`   | ABAC — child app defines fn             |
| `CombinedPolicyGuard(perm, fn)` | `@UseGuards(...)`               | RBAC OR ABAC union                      |

---

## Client SDK — Next.js

### 1. Bootstrap in root layout.tsx (server component)

```tsx
// app/layout.tsx
import { bootstrapAuth, AuthProvider } from "@jscotechlabs/auth-sdk/client";

export default async function RootLayout({ children }) {
  const authUser = await bootstrapAuth({
    childApiUrl: process.env.CHILD_API_URL, // e.g. https://hrms-api.yourdomain.com
  });

  return (
    <html>
      <body>
        <AuthProvider
          initialUser={authUser}
          loginUrl={process.env.NEXT_PUBLIC_LOGIN_URL}
        >
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}
```

---

### 2. Configure authFetch (once, in a client provider)

```tsx
"use client";
import { configureAuthFetch } from "@jscotechlabs/auth-sdk/client";

configureAuthFetch({
  containerApiUrl: process.env.NEXT_PUBLIC_CONTAINER_API_URL!,
  loginUrl: process.env.NEXT_PUBLIC_LOGIN_URL,
});
```

---

### 3. Hooks

```tsx
import {
  useAuth,
  useHasRole,
  useHasPermission,
  useHasScope,
  useCanAccess,
} from "@jscotechlabs/auth-sdk/client";

function MyComponent() {
  // Full auth state
  const { user, roles, permissions, authorityScope, isAuthenticated, logout } =
    useAuth();

  // Role check — OR logic
  const isAdmin = useHasRole("CompanyAdmin");
  const canManage = useHasRole("CompanyAdmin", "GroupAdmin");

  // Permission check — AND logic
  const canApprove = useHasPermission("leave:approve");
  const canViewPayslip = useHasPermission("employee:read", "payroll:read");

  // Scope check — hierarchy >=
  const isCompanyLevel = useHasScope("company");
  const isGroupLevel = useHasScope("group");

  // Combined check — all provided checks must pass
  const canTransfer = useCanAccess({
    roles: ["HRManager"],
    permissions: ["employee:transfer"],
    scope: "company",
  });

  return (
    <div>
      <p>Welcome {user?.email}</p>
      {isAdmin && <AdminBadge />}
      {canApprove && <ApproveButton />}
      {isCompanyLevel && <CompanyReport />}
      <button onClick={() => logout()}>Sign out</button>
    </div>
  );
}
```

---

### 4. UI Guard Components

```tsx
import {
  AuthGuard,
  RoleGuard,
  PermissionGuard,
  ScopeGuard,
  ProtectedRoute,
} from '@jscotechlabs/auth-sdk/client';

// Redirect unauthenticated users
<AuthGuard redirectTo="/login" fallback={<Spinner />}>
  <Dashboard />
</AuthGuard>

// Show only for specific roles (OR)
<RoleGuard
  roles={['CompanyAdmin', 'GroupAdmin']}
  fallback={<p>Admin access required.</p>}
>
  <AdminPanel />
</RoleGuard>

// Show only if user has permission (AND)
<PermissionGuard permissions={['leave:approve']}>
  <ApproveButton />
</PermissionGuard>

// Show only for company or group level users
<ScopeGuard scope="company">
  <CompanyWideReport />
</ScopeGuard>

// All-in-one page protection
<ProtectedRoute
  redirectTo="/login"
  roles={['HRManager']}
  permissions={['employee:transfer']}
  scope="company"
  unauthorized={<AccessDenied />}
  fallback={<PageSkeleton />}
>
  <TransferPage />
</ProtectedRoute>
```

---

### 5. API calls with authFetch

Drop-in replacement for `fetch`. Automatically handles token refresh and retry.

```ts
import {
  authFetch,
  authGet,
  authPost,
  authPatch,
  authDelete,
} from "@jscotechlabs/auth-sdk/client";

// GET
const res = await authGet("/api/employees");
const data = await res.json();

// POST
const res = await authPost("/api/leave/approve", {
  body: JSON.stringify({ leaveId }),
  headers: { "Content-Type": "application/json" },
});

// PATCH
await authPatch(`/api/employee/${id}`, {
  body: JSON.stringify(updates),
});

// DELETE
await authDelete(`/api/document/${id}`);
```

---

## Environment Variables

### Child API (NestJS)

| Variable      | Description                                  |
| ------------- | -------------------------------------------- |
| `JWT_SECRET`  | Shared JWT secret — must match Container API |
| `COOKIE_NAME` | Cookie name (default: `enterprise_access`)   |
| `MODULE_NAME` | This app's module identifier e.g. `hrms`     |

### Child UI (Next.js)

| Variable                        | Description                           |
| ------------------------------- | ------------------------------------- |
| `CHILD_API_URL`                 | Child app's own API URL (server-side) |
| `NEXT_PUBLIC_CONTAINER_API_URL` | Container API URL (client-side)       |
| `NEXT_PUBLIC_LOGIN_URL`         | Login page URL for redirects          |

---

## Local Development

Add fake subdomains to `/etc/hosts`:

```
127.0.0.1   app.yourdomain.local
127.0.0.1   api.yourdomain.local
127.0.0.1   hrms.yourdomain.local
127.0.0.1   hrms-api.yourdomain.local
```

Set cookie domain in Container API `.env`:

```
COOKIE_DOMAIN=.yourdomain.local
```

In local dev, the Container API must set `secure: false` and `sameSite: 'lax'` on the cookie (HTTP, not HTTPS).

---

## Security Model

| Concern                 | Mitigation                                   |
| ----------------------- | -------------------------------------------- |
| Token theft via URL     | Not applicable — token never in URL          |
| XSS token theft         | HttpOnly cookie — JS cannot read it          |
| CSRF                    | SameSite=Strict + origin validation          |
| Module hopping          | `ModuleAccessGuard` checks `modules[]` claim |
| Concurrent refresh race | Single shared refresh promise — no stampede  |
| Refresh token replay    | Container API rotates on every use           |

---

## License

MIT © jscotechlabs