Skip to content
Open
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
165 changes: 165 additions & 0 deletions .agents/skills/starter-auth-and-access-control/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
---
name: starter-auth-and-access-control
description: >-
Use this skill when implementing authentication, protecting routes, verifying resource
ownership, enforcing role-based access control (RBAC), accessing req.me, or configuring
trusted identity injection in StartER to prevent IDOR security vulnerabilities.
---

# Authentication & Access Control in StartER

StartER implements a stateless, cookie-based passwordless authentication model (**Magic Link**) combined with a strict **4-layer defense-in-depth access control architecture**.

Follow this guide whenever securing endpoints, verifying user ownership, or accessing user session state.

---

## The 4-Layer Defense-in-Depth Model

Every secured request passes through four explicit security boundaries before reaching controller actions:

```
Request: PUT /api/items/42
│
▼
[ Layer 1: ParamConverter ] ────> Resolves :itemId into req.item (404 fast-fail if not found)
│
▼
[ Layer 2: Authentication Wall ] > Verifies __Host-auth cookie JWT, loads & injects req.me (401 if missing)
│
▼
[ Layer 3: Authorization Guard ] ─> Checks ownership: req.item.user_id === req.me.id (403 if mismatch)
│
▼
[ Layer 4: Edge Validation ] ────> Validates DTO & forcefully injects req.me.id into req.body (Prevents IDOR)
│
▼
[ Action Controller ] ──────────> Operates on trusted, fully-typed req.body and req.item
```

---

## 1. The Golden Security Rule: Never Trust Client User IDs

> [!CAUTION]
> **Accepting `user_id` from client payloads causes Insecure Direct Object Reference (IDOR) vulnerabilities.**
>
> ❌ **Forbidden**:
> ```typescript
> // NEVER accept user_id from the client!
> export const ItemDTOSchema = z.object({
> title: z.string(),
> user_id: z.number(), // CRITICAL VULNERABILITY: Attacker can spoof ownership!
> });
> ```
>
> ✅ **Mandatory**:
> ```typescript
> // Strip user_id from client input DTO:
> export const ItemDTOSchema = ItemSchema.omit({ id: true, user_id: true });
> ```

Always inject `user_id` server-side via `createValidator`:
```typescript
const add = createValidator(
{ body: ItemDTOSchema },
{ inject: (req) => ({ user_id: req.me.id }) }, // Authenticated session identity
);
```

> [!IMPORTANT]
> Consult [trusted-injections.md](./references/trusted-injections.md) for full details on DTO `.omit()` schemas and payload enrichment.

---

## 2. The Authentication Wall Pattern

Organize router files (`*Routes.ts`) with an explicit visual boundary between public and protected routes:

```typescript
import { Router } from "express";
import authActions from "../auth/authActions";
import itemActions from "./itemActions";
import itemParamConverter from "./itemParamConverter";
import itemValidators from "./itemValidators";

const router = Router();
const BASE_PATH = "/api/items";
const ITEM_PATH = "/api/items/:itemId";

// 1. Resolve :itemId into req.item on all matching routes
router.param("itemId", itemParamConverter.convert);

// 2. Public Read Endpoints (No authentication needed)
router.get(BASE_PATH, itemActions.browse);
router.get(ITEM_PATH, itemActions.read);

// ────────────────────── AUTHENTICATION WALL ──────────────────────
// Everything below this line strictly requires a valid session cookie:
router.use(BASE_PATH, authActions.verifyAccessToken);

// 3. Authenticated Create (Any logged-in user)
router.post(BASE_PATH, itemValidators.add, itemActions.add);

// 4. Authenticated & Authorized Mutations (Owner only)
router
.route(ITEM_PATH)
.all(checkAccess)
.put(itemValidators.edit, itemActions.edit)
.delete(itemActions.destroy);

export default router;
```

---

## 3. Ownership Verification (`checkAccess`)

Define ownership middleware directly in the router file to keep security rules auditable and local to the resource:

```typescript
import type { RequestHandler } from "express";

const checkAccess: RequestHandler = (req, res, next) => {
// req.item is injected by ParamConverter
// req.me is injected by verifyAccessToken
if (req.item.user_id === req.me.id) {
return next();
}
res.sendStatus(403); // Forbidden
};
```

> [!IMPORTANT]
> Consult [ownership-and-rbac.md](./references/ownership-and-rbac.md) for role-based access control (RBAC), admin overrides, and fail-fast middleware ordering.

---

## 4. Accessing User Identity

### Server-side: `req.me`
After passing through `authActions.verifyAccessToken`:
- `req.me` is populated with the complete `User` record fetched freshly from the database (`userRepository.find(sub)`).
- If the user was deleted from SQLite, the token is rejected immediately, even if unexpired.
- TypeScript types for `req.me` are globally augmented.

### Client-side: `useMe()` Hook
In React components:
```tsx
import { useMe } from "../auth/MeContext";

function UserProfile() {
const { user, isAuthenticated, logout } = useMe();

if (!isAuthenticated || !user) {
return <p>Please log in.</p>;
}

return (
<div>
<h2>Welcome, {user.name}!</h2>
<button type="button" onClick={logout}>Sign Out</button>
</div>
);
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Ownership & Role-Based Access Control (RBAC)

This reference documents how to enforce resource ownership and role-based permissions in StartER routers.

---

## 1. Ownership Verification (`checkAccess`)

In StartER, resource ownership logic is declared **directly in the resource's routes file** (`*Routes.ts`). This ensures authorization rules are obvious, auditable, and fail-fast.

```typescript
import type { RequestHandler } from "express";

/*
Ownership check:
- Assumes req.item was injected by ParamConverter
- Assumes req.me was injected by verifyAccessToken
*/
const checkAccess: RequestHandler = (req, res, next) => {
if (req.item.user_id === req.me.id) {
return next(); // User is the owner, allow request
}
res.sendStatus(403); // Forbidden
};
```

---

## 2. Router Composition with `.all()`

Apply ownership checks cleanly across multiple HTTP methods on a path using `router.route().all()`:

```typescript
// Routes for /api/items/:itemId
router
.route(ITEM_PATH)
.all(checkAccess) // Enforces ownership on ALL handlers below
.put(itemValidators.edit, itemActions.edit)
.delete(itemActions.destroy);
```

### Why `.all(checkAccess)`:
- Guarantees neither `PUT` nor `DELETE` can execute without ownership verification.
- Rejects unauthorized users before validation middleware (`itemValidators.edit`) runs.
- Eliminates repetitive `if (item.user_id !== req.me.id)` conditionals inside controller actions.

---

## 3. Role-Based Access Control (RBAC)

When an application supports administrator roles or elevated permissions, combine ownership checks with role evaluation:

```typescript
const checkAccessOrAdmin: RequestHandler = (req, res, next) => {
const isOwner = req.item.user_id === req.me.id;
const isAdmin = req.me.role === "admin";

if (isOwner || isAdmin) {
return next();
}

res.sendStatus(403);
};
```

### Dedicated Admin-Only Guards
For endpoints restricted exclusively to staff or administrators:

```typescript
const requireAdmin: RequestHandler = (req, res, next) => {
if (req.me.role === "admin") {
return next();
}
res.sendStatus(403);
};

router.delete("/api/admin/purge", requireAdmin, adminActions.purge);
```

---

## 4. The Authentication Wall Pattern

Group public and protected routes around an explicit boundary:

```typescript
const BASE_PATH = "/api/posts";
const POST_PATH = "/api/posts/:postId";

// 1. Resolve :postId parameter on all matching routes
router.param("postId", postParamConverter.convert);

// 2. Public Read Endpoints (No auth needed)
router.get(BASE_PATH, postActions.browse);
router.get(POST_PATH, postActions.read);

// ────────────────────── AUTHENTICATION WALL ──────────────────────
// Everything below this line strictly requires an active user session:
router.use(BASE_PATH, authActions.verifyAccessToken);

// 3. Authenticated Create (Any logged-in user)
router.post(BASE_PATH, postValidators.add, postActions.add);

// 4. Authenticated & Authorized Mutations (Owner only)
router
.route(POST_PATH)
.all(checkAccess)
.put(postValidators.edit, postActions.edit)
.delete(postActions.destroy);
```

### Benefits of the Wall:
- **Zero Duplication**: `verifyAccessToken` is applied once to `BASE_PATH`, protecting all subsequent routes automatically.
- **Fail-Fast**: Anonymous users receive a `401 Unauthorized` before any validator or action executes.
- **Clear Security Audit**: Developers can instantly verify which routes are public vs protected simply by reading the router from top to bottom.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Trusted Injections & Eliminating IDOR Vulnerabilities

This reference documents how StartER uses Zod DTOs and `createValidator` injections to completely neutralize Insecure Direct Object Reference (IDOR) attacks.

---

## The IDOR Threat Model

An Insecure Direct Object Reference (IDOR) occurs when an application relies on client-supplied identity attributes (such as `user_id`, `account_id`, or `author_id`) to associate records with users.

### ❌ The Vulnerable Pattern:
```typescript
// DANGEROUS: Accepts user_id from client JSON payload
export const BadItemDTOSchema = z.object({
title: z.string(),
user_id: z.number(), // VULNERABILITY!
});

// An attacker sends:
// POST /api/items
// { "title": "Malicious Item", "user_id": 1 } <-- Hijacks admin identity!
```

If the backend trusts `req.body.user_id`, any authenticated user can create or manipulate records under another user's account.

---

## The StartER Solution: DTO Separation + Server-Side Injection

StartER eliminates IDOR vulnerabilities at the architectural boundary by strictly separating client input from server-enforced identity.

### 1. DTO Schema Omits Sensitive Fields (`*Schemas.ts`)

The input boundary schema derived from the master entity schema **always** strips `id` and `user_id`:

```typescript
export const ItemSchema = z.object({
id: z.number(),
title: z.string().max(255),
user_id: z.number(),
});

// Client Input Boundary: Client can ONLY supply `title`
export const ItemDTOSchema = ItemSchema.omit({
id: true,
user_id: true,
});

export type ItemDTO = z.infer<typeof ItemDTOSchema>;

// Downstream Action Contract: Enriched with trusted server data
export type ItemDTOWithUserId = ItemDTO & {
user_id: User["id"];
};
```

Even if a malicious client sends `{"title": "Test", "user_id": 999}`, Zod's parsing automatically strips or ignores the untrusted `user_id`.

---

### 2. Forceful Server Injection (`*Validators.ts`)

Validation middleware configured via `createValidator` forcefully injects trusted identity attributes extracted from `req.me` (authenticated session) and `req.<entity>` (resolved param converter):

```typescript
import { createValidator } from "../../helpers/validation";
import { ItemDTOSchema } from "./itemSchemas";

// Add Validator (POST /api/items)
const add = createValidator(
{ body: ItemDTOSchema },
{
inject: (req) => ({
user_id: req.me.id, // Strictly from verified session JWT
}),
},
);

// Edit Validator (PUT /api/items/:itemId)
const edit = createValidator(
{ body: ItemDTOSchema },
{
inject: (req) => ({
id: req.item.id, // Strictly from resolved ParamConverter
user_id: req.me.id, // Strictly from verified session JWT
}),
},
);

export default { add, edit };
```

---

### 3. Downstream Controller Safety (`*Actions.ts`)

Because `createValidator` replaces `req.body` with the merged, validated payload:
- Downstream actions receive a clean, 100% trusted `req.body`.
- Actions do not need manual ID reassignment.
- Repositories receive genuine user IDs:
```typescript
const add: RequestHandler = (req, res) => {
// req.body is typed ItemDTOWithUserId; user_id is guaranteed authentic
const insertId = itemRepository.create(req.body);
res.status(201).json({ insertId });
};
```
Loading
Loading