# User Authentication and Authorization

This document describes the user authentication system in the Acequia/localWebDAV server, including user creation, verification, roles, and permissions.

> **Client Implementation**: The client-side JWT and authentication logic is implemented in the `../acequia2/` project, which provides `acequia.js` and the service worker. See [acequia2/acequia/acequia.js](../acequia2/acequia/acequia.js) and [acequia2/auth/tokens.js](../acequia2/auth/tokens.js) for the source.

## Overview

The system supports three authentication modes, detected by JWT claim inspection in `src/auth/jwtAuth.mjs`:

1. **Chain Tokens** (`parent` claim present) — Delegation chains verified via `src/chains.mjs`. Scope enforced by the chain's `paths`/`writePaths`. Root traces to a registered user.
2. **User Tokens** (`kid` claim, no `parent`) — Direct user auth verified against the user's registered public key. Role-based access.
3. **Legacy Device Tokens** (no `kid`, no `parent`) — Deprecated. Verified against `/devices/{subdomain}/{deviceId}.json`.

**All requests must include a JWT** that is verifiable and contains the claims required to authorize the endpoint.

> **Chain tokens** are the primary auth mechanism going forward. User tokens (no `parent` claim) are effectively depth-0 chain tokens. See `documents/projects/capability-delegation.md` for the full architecture and `documents/projects/auth-status.md` for implementation status.

---

## JWT (JSON Web Token) Architecture

### Cryptographic Foundation

| Property | Value |
|----------|-------|
| **Algorithm** | PS256 (RSA-PSS with SHA-256) |
| **Key Size** | 2048+ bits |
| **Library** | [jose](https://github.com/panva/jose) v6.x |
| **Key ID (kid)** | RFC 7638 JWK Thumbprint |

### Token Structure

All JWTs follow the standard three-part structure: `header.payload.signature`

**Protected Header:**
```json
{
  "alg": "PS256",
  "kid": "<JWK thumbprint>"
}
```

**Payload (Claims):**
```json
{
  "sub": "<userId or deviceId>",
  "iss": "<issuer>",
  "iat": 1704067200,
  "exp": 1704153600,
  "kid": "<key identifier>",
  "deviceId": "<device attribution>",
  "roles": ["admin"],
  "paths": ["*"],
  "writePaths": ["/documents/*"]
}
```

### Standard JWT Claims (RFC 7519)

| Claim | Name | Description | Example |
|-------|------|-------------|---------|
| `sub` | Subject | Primary identity (userId or deviceId) | `"o5bf70bw9d36jffroivaneaj"` |
| `iss` | Issuer | Who created the token | `"deviceId"` or `"hostname"` |
| `iat` | Issued At | Unix timestamp of creation | `1704067200` |
| `exp` | Expiration | Unix timestamp when token expires | `1704153600` |

### Acequia Custom Claims

| Claim | Purpose | Required | Example |
|-------|---------|----------|---------|
| `kid` | Key identifier (JWK thumbprint) | User tokens | `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"` |
| `deviceId` | Device attribution | User tokens | `"ggtx3t3hyhm92w6647lv8a3w"` |
| `roles` | Authorization roles | Optional | `["admin", "superuser"]` |
| `paths` | Allowed read paths | Optional | `["*", "/documents/*"]` |
| `writePaths` | Allowed write paths | Optional | `["/uploads/*"]` |
| `permissions` | Legacy path permissions | Legacy | `{ "/path": { "read": true } }` |

---

## Token Types

### User Tokens (Recommended)

User tokens identify a **user** and include a `kid` claim to specify which device key signed the token. This enables:
- Single user identity across multiple devices
- Per-device key revocation
- Forensic attribution of actions to specific devices

**Claims:**
```json
{
  "sub": "userId",
  "iss": "hostname",
  "iat": 1704067200,
  "kid": "<JWK thumbprint>",
  "deviceId": "deviceId"
}
```

**Client Creation** (`acequia2/auth/tokens.js`):
```javascript
export async function createUserToken(payload, expiresTime) {
  let { userId, userKeys, userPublicJWK, deviceId } = await acequiaReady()

  const kid = await jose.calculateJwkThumbprint(userPublicJWK)

  let token = new jose.SignJWT({
    ...payload,
    kid,
    deviceId,
  })
    .setSubject(userId)
    .setIssuedAt()
    .setIssuer(location.hostname)
    .setProtectedHeader({ alg: 'PS256', kid })

  if (expiresTime) token.setExpirationTime(expiresTime)
  return await token.sign(userKeys.privateKey)
}
```

**Server Verification** (`src/auth/jwtAuth.mjs`):
1. Decode JWT to extract `sub` (userId) and `kid`
2. Load user from `auth/{subdomain}/users/{userId}.json`
3. Find the key entry matching `kid`
4. Verify signature with that specific public key
5. Check key status is not `revoked`

### Legacy Device Tokens

Device tokens identify a **device** and do not include a `kid` claim. Used for backward compatibility.

**Claims:**
```json
{
  "sub": "deviceId",
  "iss": "deviceId",
  "iat": 1704067200
}
```

**Server Verification:**
1. Decode JWT to extract `sub` (deviceId)
2. Load device from `devices/{subdomain}/{deviceId}.json`
3. Verify signature with device's public key

### Chain Tokens (Delegation)

Chain tokens carry a `parent` claim linking to the SHA-256 hash of the parent JWT, forming a verifiable delegation chain. Each link can only narrow scope (attenuation), never widen it.

**Claims:**
```json
{
  "sub": "userId",
  "iss": "delegatorUserId",
  "iat": 1704067200,
  "exp": 1704153600,
  "kid": "<delegator's JWK thumbprint>",
  "parent": "<sha256 hash of parent JWT>",
  "depth": 1,
  "max_depth": 3,
  "scope": {
    "paths": ["/docs/*"],
    "writePaths": ["/docs/shared/*"]
  }
}
```

**Storage:** Content-addressable at `auth/{subdomain}/chains/{sha256}.jwt` — filename is SHA-256 of the JWT string.

**Client Creation** (`acequia2/auth/chains.js`):
```javascript
const result = await acequia.chains.createChainToken({
  authUrl: '/auth',
  authToken: currentUserJwt,
  name: 'Shared access',
  scope: { paths: ['/docs/*'], writePaths: [] },
  expiresInSeconds: 3600,
})
// result: { leafJwt, leafHash, rootHash }
```

This creates a two-token chain: root (user's full scope) + scoped child.

**Server Verification** (`src/chains.mjs`):
1. Resolve chain: walk `parent` hash pointers from leaf to root, loading each JWT from chain store
2. Verify root: check `sub` is a registered user, find key by `kid`, verify signature, check revocation
3. For each subsequent link:
   - Verify `parent` hash matches previous JWT
   - Verify `depth` increments correctly and ≤ `max_depth`
   - Verify scope is subset of parent (`isScopeSubset`)
   - Verify expiry ≤ parent expiry
   - Verify signature using delegator's public key
   - Check revocation list
4. Return `effectiveScope` from the leaf token

**Detection:** The server routes token types by claim inspection in `jwtAuth.mjs`:
```javascript
if (decToken.parent) {
  return await this.authenticateChainToken(req, resp, decToken)
} else if (decToken.kid) {
  return await this.authenticateUserToken(req, resp, decToken)
} else {
  return await this.authenticateDeviceToken(req, resp, decToken)
}
```

**Revocation:** Revoked token hashes stored in `auth/{subdomain}/revocations.json`. Checked at each link during verification. See also `src/revocations.mjs`.

---

## Key Generation and Storage (Client-Side)

The acequia client generates and stores keys in IndexedDB, ensuring private keys **never leave the device**.

### Key Types

| Key Type | Storage | Purpose | Lifetime |
|----------|---------|---------|----------|
| Device Keys | IndexedDB | Device identity | Permanent |
| User Keys | IndexedDB | User identity (per-device) | Permanent |

### Key Generation (`acequia2/acequia/acequia.js`)

```javascript
// Device keys - permanent per-device identity
async function ensureKeys() {
  let keys = await acequiaStore.getItem('keys')
  if (!keys) {
    keys = await jose.generateKeyPair('PS256', {
      extractable: true,
      keyUsages: ['encrypt', 'decrypt', 'sign', 'verify'],
    })
    keys = await acequiaStore.setItem('keys', keys)
  }
  return keys
}

// User keys - per-device user identity (never transferred)
async function ensureUserKeys() {
  let userKeys = await acequiaStore.getItem('userKeys')
  if (!userKeys) {
    userKeys = await jose.generateKeyPair('PS256', {
      extractable: true,
      keyUsages: ['sign', 'verify'],
    })
    userKeys = await acequiaStore.setItem('userKeys', userKeys)
  }
  return userKeys
}
```

### ID Generation

| ID | Format | Storage | Scope |
|----|--------|---------|-------|
| `deviceId` | CUID2 | IndexedDB | Permanent per browser profile |
| `userId` | CUID2 | IndexedDB | Permanent, same across devices |
| `instanceId` | CUID2 | sessionStorage | Per browser session |

### Key ID (kid) Calculation

The `kid` is calculated as the RFC 7638 JWK Thumbprint of the public key:

```javascript
const kid = await jose.calculateJwkThumbprint(publicJWK)
```

This creates a deterministic hash that:
- Uniquely identifies the key
- Allows the server to find the correct public key for verification
- Never changes for a given key

---

## Token Verification Flow

### Request Processing

1. **Token Extraction** (precedence order):
   - Query parameter: `?token=...`
   - Authorization header: `Bearer ...`
   - Cookie: `auth_token=...`

2. **Token Decoding** (without verification):
   ```javascript
   const decToken = jose.decodeJwt(req.token)
   ```

3. **Token Type Detection**:
   ```javascript
   if (decToken.kid) {
     return await this.authenticateUserToken(req, resp, decToken)
   } else {
     return await this.authenticateDeviceToken(req, resp, decToken)
   }
   ```

### User Token Verification (`src/auth/jwtAuth.mjs`)

```javascript
async authenticateUserToken(req, resp, decToken) {
  const { sub: userId, kid, deviceId } = decToken

  // Load user record
  const user = await this.loadUser(req.subdomain, userId)
  if (!user) throw new UnauthorizedError(`User not found: ${userId}`)

  // Find key by kid
  const keyEntry = user.publicKeys?.find((k) => k.kid === kid)
  if (!keyEntry) throw new UnauthorizedError(`Key not found: ${kid}`)

  // Check key status
  if (keyEntry.status === 'revoked') {
    throw new UnauthorizedError(`Key has been revoked: ${kid}`)
  }

  // Import and verify
  const publicKey = await jose.importJWK(keyEntry.publicKey, 'PS256')
  const { payload } = await jose.jwtVerify(req.token, publicKey)

  // Build authenticated user object
  const userObj = new User({ username: userId })
  userObj.token = payload
  userObj.role = user.role
  userObj.userId = userId
  userObj.kid = kid
  userObj.legacyAuth = false

  return userObj
}
```

### Legacy Device Token Verification

```javascript
async authenticateDeviceToken(req, resp, decToken) {
  const id = decToken.sub

  // Load device info
  const deviceInfoPath = path.join(this.publicKeyPath, req.subdomain, `${id}.json`)
  const device = JSON.parse(await readFile(deviceInfoPath))

  // Verify with device's public key
  const { payload } = await jose.jwtVerify(req.token, device.publicKey)

  const userObj = new User({ username: id })
  userObj.token = payload
  userObj.legacyAuth = true
  userObj.deviceId = id

  return userObj
}
```

---

## Authorization

After authentication, the system checks authorization based on the request method and user role.

### Write Methods

```javascript
const writeMethod = ['PUT', 'POST', 'MKCOL', 'DELETE', 'MOVE', 'COPY',
                     'PROPPATCH', 'LOCK', 'UNLOCK'].includes(method)
```

### Role-Based Access

| Role | Read Access | Write Access |
|------|-------------|--------------|
| `owner` | Full (`*`) | Full (`*`) |
| `editor` | `paths` array | `writePaths` array |
| `viewer` | `paths` array | None |
| `pending` | None | None |

### Path Matching

```javascript
matchesPath(requestPath, allowedPaths) {
  for (const permPath of allowedPaths) {
    if (permPath === '*') return true                              // Wildcard
    if (permPath === requestPath) return true                      // Exact match
    if (permPath.endsWith('/*') &&
        requestPath.startsWith(permPath.slice(0, -1))) return true // Prefix match
  }
  return false
}
```

### Legacy Token Authorization

For tokens with `roles` claim:
```javascript
if (payload.roles?.includes('admin') || payload.roles?.includes('superuser')) {
  return true  // Full access
}
```

For tokens with `permissions` claim:
```javascript
const pathPerms = payload.permissions[path] || payload.permissions['*'] || {}
if (pathPerms.read) return true   // Read access
if (pathPerms.write) return true  // Write access
```

---

## Site Administration

### Site Configuration (`auth/site.json`)

The site-level configuration defines server-wide administrators:

```json
{
  "admins": ["userId1", "userId2"],
  "created": "2024-01-01T00:00:00Z",
  "createdBy": "userId1"
}
```

**Site admins can:**
- Create new subdomains via the admin interface
- Access the subdomain creation API (`POST /api/subdomain/create`)

### Initial Site Setup

When the server starts without a `site.json`:

1. Browser requests are redirected to a setup page
2. The first user to complete setup becomes the site administrator
3. The setup process:
   - Registers a new user (or uses existing identity)
   - Creates a JWT token with admin roles
   - Calls `POST /api/site/setup` to create `site.json`

---

## Subdomain Ownership

### Subdomain Configuration (`auth/{subdomain}/subdomain.json`)

Each subdomain has an owner defined in its configuration:

```json
{
  "subdomain": "example",
  "ownerId": "userId",
  "createdAt": "2024-01-01T00:00:00Z",
  "updatedAt": "2024-01-01T00:00:00Z"
}
```

The subdomain owner has full control over:
- User management within the subdomain
- Access permissions
- Invite management

---

## User Roles

Users within a subdomain can have one of the following roles:

| Role | Read Access | Write Access | User Management |
|------|-------------|--------------|-----------------|
| `owner` | Full | Full | Yes |
| `editor` | Granted paths | Granted writePaths | No |
| `viewer` | Granted paths | None | No |
| `pending` | None | None | No |

### Role Definitions

- **owner**: Full read/write access to all paths, can manage users and invites
- **editor**: Read access to `paths`, write access to `writePaths`
- **viewer**: Read-only access to `paths`
- **pending**: No access; awaiting approval from owner

### Managing Pending Users

When users register directly (without an invite), they receive `pending` status. The owner can manage these users via:

1. **Owner Dashboard** (`/owner-dashboard.html`)
   - View pending users tab
   - Approve with role/path selection
   - Deny (delete user)

2. **API Endpoints**
   - `PATCH /auth/users/:userId` - Update role and permissions
   - `DELETE /auth/users/:userId` - Remove user

**Approval Flow:**
```
User registers directly → pending status
           │
           ▼
Owner sees user in dashboard "Pending" tab
           │
           ├── Approve → Select role, set paths → PATCH /auth/users/:userId
           │
           └── Deny → DELETE /auth/users/:userId
```

**Approval Request Example:**
```json
PATCH /auth/users/abc123
{
  "role": "editor",
  "paths": ["/documents/*"],
  "writePaths": ["/documents/*"]
}
```

### Alternative: Using Invites for Pending Users

Instead of approving pending users manually, owners can send them an invite link. When the pending user accepts the invite:

- Their status changes from `pending` to the invite's role
- They receive the invite's path permissions
- The invite's `mode` determines if permissions replace or merge

This is useful when you want to grant specific permissions without navigating the approval UI.

---

## User Registration Flow

There are two paths to become a registered user on a subdomain:

### Path 1: Direct Registration

Direct registration is used when a user discovers a subdomain and wants to join. The first registrant becomes the owner; subsequent users get `pending` status awaiting owner approval.

1. Client generates a keypair (PS256 algorithm) via `acequia.js`
2. Client calls `POST /auth/users/register` with:
   ```json
   {
     "subdomain": "example",
     "userId": "generatedUserId",
     "publicKey": { /* JWK format */ },
     "kid": "<JWK thumbprint>",
     "deviceId": "deviceIdentifier",
     "deviceName": "My Laptop"
   }
   ```
3. Server creates user file at `auth/{subdomain}/users/{userId}.json`
4. Role assignment:
   - **First user** → `owner` role with full access (`*`)
   - **Subsequent users** → `pending` role with no access

### Path 2: Invite-Based Registration

Invite-based registration is used when an owner shares an invite link. Users get immediate access with the permissions defined in the invite.

1. Owner creates invite via dashboard or API (`POST /auth/invites`)
2. User receives invite URL: `https://subdomain.example.com/accept-invite.html?invite={inviteId}`
3. User visits the link, acequia.js generates/loads their keypair
4. User clicks "Accept Invitation"
5. Client calls `POST /auth/invites/accept` with credentials
6. Server creates user with invite's role and permissions
7. Auth token is created and stored in cookie

**Invite Flow Diagram:**
```
Owner creates invite
        │
        ▼
  Invite URL shared
        │
        ▼
User visits accept-invite.html
        │
        ├── acequia.acequiaReady()
        │       └── Generates/loads userId, deviceId, userKeys
        │
        ├── GET /auth/invites/{inviteId}
        │       └── Display invite details, mode, permissions
        │
        ▼
User clicks "Accept Invitation"
        │
        ├── Export public key to JWK
        ├── Calculate kid (JWK thumbprint)
        │
        ▼
POST /auth/invites/accept
        │
        ├── New user? → Create user with invite permissions
        └── Existing user? → Update permissions based on mode
        │
        ▼
createUserToken() → Store in cookie → Redirect to /
```

### Registration Behavior Summary

| Scenario | Role Assigned | Paths | Status |
|----------|---------------|-------|--------|
| First direct registration | `owner` | `*` | Immediate access |
| Subsequent direct registration | `pending` | none | Awaiting approval |
| Invite acceptance (new user) | Invite's role | Invite's paths | Immediate access |
| Invite acceptance (existing, replace) | Invite's role | Invite's paths | Updated |
| Invite acceptance (existing, merge) | Higher role | Union of paths | Updated |

### Client Registration (`acequia2/auth/auth.js`)

```javascript
export async function registerUser(authURL) {
  let { userId, deviceId, keys } = await checkAuth()

  // Export public key to JWK format
  let publicKey = await acequia.tokens.jose.exportJWK(keys.publicKey)
  let kid = await acequia.tokens.jose.calculateJwkThumbprint(publicKey)

  let registerBody = {
    userId,
    deviceId,
    publicKey,
    kid,
  }

  let resp = await fetch(authURL + '/users/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(registerBody),
  })

  return resp.json()
}
```

### User File Structure (`auth/{subdomain}/users/{userId}.json`)

```json
{
  "userId": "abc123",
  "subdomain": "example",
  "role": "editor",
  "createdAt": "2024-01-01T00:00:00Z",
  "publicKeys": [
    {
      "kid": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs",
      "publicKey": {
        "kty": "RSA",
        "e": "AQAB",
        "n": "sXiwwtYQwqI...",
        "alg": "PS256"
      },
      "deviceId": "device1",
      "deviceName": "Laptop",
      "addedAt": "2024-01-01T00:00:00Z",
      "lastUsed": "2024-01-02T00:00:00Z",
      "status": "active"
    }
  ],
  "paths": ["*"],
  "writePaths": ["/documents/*"]
}
```

---

## Multi-Device Support

Each device generates its own unique keypair. Private keys **never** transfer between devices.

### Adding a New Device Key

1. Existing device creates an endorsement token
2. New device calls `POST /auth/users/{userId}/keys` with:
   ```json
   {
     "publicKey": { /* JWK */ },
     "kid": "<new key thumbprint>",
     "deviceId": "newDevice",
     "deviceName": "New Phone",
     "endorsedBy": "existingKeyKid"
   }
   ```
3. Server verifies the request is authenticated with an existing key
4. New key added to user's `publicKeys` array

### Key Revocation

Keys can be revoked via `DELETE /auth/users/{userId}/keys/{kid}`:
- Sets key status to `revoked`
- Revoked keys cannot be used for authentication
- Other devices continue to work normally

### Key Purge (Permanent Deletion)

Revoked keys can be permanently deleted via `DELETE /auth/users/{userId}/keys/{kid}/purge`:
- Only works on keys with `status: 'revoked'`
- Removes the key entry entirely from the `publicKeys` array
- Requires same authorization as revoke (user owns the key, or subdomain owner)

---

## Device Linking (Login Flow)

When a user wants to access their account from a new device, they use the device linking flow instead of re-registering. This allows a single user identity across multiple devices.

### Overview

The device link flow is similar to invites but for the same user:
- **Invites**: Owner creates token → any user accepts → creates new user
- **Device Link**: User creates token → same user accepts → adds device key to existing user

### Device Link Token Structure

```javascript
{
  type: "device-link",
  userId: "existing_user_id",
  subdomain: "target_subdomain",
  createdBy: {
    kid: "endorsing_key_id",
    deviceId: "endorsing_device_id"
  },
  exp: 1704153600,  // 5 minute expiry
  singleUse: true
}
// Signed with user's private key from existing device
```

### Flow Diagram

```
┌────────────────────────────────────────────────────────────────────────┐
│                     DEVICE A (Existing, logged in)                      │
├────────────────────────────────────────────────────────────────────────┤
│ 1. User clicks "Link New Device" in dashboard (My Devices tab)         │
│ 2. Creates signed device-link token (5 min expiry)                     │
│ 3. Displays QR code + short code (6 alphanumeric characters)           │
│ 4. Shows "waiting for device..." status                                │
└────────────────────────────────────────────────────────────────────────┘
                                    │
                           QR code / short code
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│                     DEVICE B (New device)                               │
├────────────────────────────────────────────────────────────────────────┤
│ 1. User visits /login.html                                             │
│ 2. Enters short code OR scans QR → navigates to link URL               │
│ 3. Page decodes token, extracts userId                                 │
│ 4. Generates new userKeys for this device                              │
│ 5. Sends token + new publicKey to server                               │
│ 6. Server verifies token signature, adds key to user                   │
│ 7. Device stores userId + userKeys in IndexedDB                        │
│ 8. User is now logged in on Device B                                   │
└────────────────────────────────────────────────────────────────────────┘
```

### Short Codes

For easier entry on mobile devices, device link tokens are also displayed as 6-character alphanumeric short codes:

- Generated on the server when creating a device link
- Stored temporarily with 5-minute expiry
- Maps to the full JWT token
- Users can enter the short code instead of scanning QR

### Client-Side API (`acequia2/auth/deviceLink.js`)

**Create Device Link Token (on existing device):**
```javascript
import { createDeviceLinkToken } from 'acequia/deviceLink.js'

// Returns { token, shortCode, qrUrl }
const link = await createDeviceLinkToken(subdomain, expiresInMinutes = 5)
```

**Accept Device Link (on new device):**
```javascript
import { acceptDeviceLink } from 'acequia/deviceLink.js'

// Can accept either short code or full token
await acceptDeviceLink(tokenOrShortCode, authURL)
// Sets userId and userKeys in IndexedDB
// User is now logged in
```

### Server Endpoint

`POST /auth/device-link/accept`:
```json
{
  "token": "<JWT or short code>",
  "newPublicKey": { /* JWK */ },
  "newDeviceId": "deviceId",
  "newKid": "<JWK thumbprint>"
}
```

**Response:**
```json
{
  "success": true,
  "message": "Device linked successfully",
  "userId": "existingUserId",
  "handle": "username"
}
```

### Handling Existing Identity on New Device

When accepting a device link, the new device may already have a different userId in IndexedDB. The `acceptDeviceLink` function handles this by:

1. Detecting the existing userId
2. Clearing the old identity (userId, userKeys)
3. Storing the new identity from the link

### Security Considerations

| Property | Implementation |
|----------|----------------|
| **Short expiry** | Device link tokens expire in 5 minutes |
| **Single use** | Token can only be used once |
| **Signed** | Token is signed with user's private key |
| **Verified** | Server verifies signature against registered keys |
| **No key transfer** | Private keys never leave the device |

---

## Revoked Key Authentication Handling

When a device's key has been revoked, authentication requests return HTTP 401 with specific error codes.

### Server Middleware Behavior

The `populateAuthenticatedUser` middleware in `server.mjs` handles revoked keys:

```javascript
if (!keyEntry) {
  return res.status(401).json({
    success: false,
    message: 'Authentication key not found',
    code: 'KEY_NOT_FOUND'
  })
}

if (keyEntry.status !== 'active') {
  return res.status(401).json({
    success: false,
    message: 'Authentication key has been revoked',
    code: 'KEY_REVOKED'
  })
}
```

### Client-Side Handling

When clients receive a 401 with `KEY_REVOKED` or `KEY_NOT_FOUND`:

1. Clear the local user identity from IndexedDB
2. Show the registration/login form
3. Prevent redirect loops

```javascript
if (resp.status === 401) {
  const data = await resp.json()
  if (data.code === 'KEY_REVOKED' || data.code === 'KEY_NOT_FOUND') {
    await acequia.deviceLink.clearUserIdentity()
    // Show registration form
  }
}
```

---

## Stored Tokens (API Tokens)

Stored tokens allow users to create persistent, named token references for automation, scripts, and API access. Instead of using a JWT directly, the user stores the JWT on the server and receives a token ID (CUID2) that can be used in its place.

### Overview

- **Token IDs** are CUID2 strings (24-25 lowercase alphanumeric characters)
- Token IDs can be used anywhere a JWT is accepted: Authorization header, auth_token cookie, or query parameter
- The server automatically resolves token IDs to their stored JWTs
- Users manage tokens from the dashboard "API Tokens" section

### Token Resolution Flow

```
Request with token ID
     │
     ├──► Extract token (query > header > cookie)
     ├──► Check if token looks like CUID2 (no dots, 20-30 chars)
     │
     └── Is CUID2? ──► Stored Token Resolution
             │
             ├──► Load from auth/{subdomain}/stored-tokens/{tokenId}.json
             ├──► Check status !== 'revoked'
             ├──► Check not expired
             ├──► Replace req.token with stored JWT
             └──► Continue to normal JWT authentication
```

### Token Data Structure

**Storage Location:** `auth/{subdomain}/stored-tokens/{tokenId}.json`

```json
{
  "tokenId": "vaaopsz2qx1yo03jn7l0x6fd",
  "userId": "owner_user_id",
  "jwt": "eyJhbGciOiJQUzI1NiJ9...",
  "name": "My API Token",
  "note": "For automation scripts",
  "createdAt": "2024-02-09T10:30:00.000Z",
  "lastUsed": "2024-02-09T15:45:00.000Z",
  "expiresAt": "2025-02-09T10:30:00.000Z",
  "status": "active",
  "revokedAt": null
}
```

### Creating a Stored Token

**Client-side (Dashboard):**
```javascript
// 1. Create a long-lived JWT using acequia
const jwt = await acequia.tokens.createUserToken({}, '365d')

// 2. Send to server with metadata
const resp = await fetch('/auth/stored-tokens', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${sessionToken}`
  },
  body: JSON.stringify({
    jwt,
    name: 'CI/CD Token',
    note: 'For GitHub Actions',
    expiresInDays: 365
  })
})

// 3. Get token ID to use
const { tokenId } = await resp.json()
// tokenId: "vaaopsz2qx1yo03jn7l0x6fd"
```

### Using a Stored Token

The token ID can be used in any of these ways:

```bash
# Authorization header
curl -H "Authorization: Bearer vaaopsz2qx1yo03jn7l0x6fd" https://example.com/api/data

# Query parameter
curl "https://example.com/api/data?token=vaaopsz2qx1yo03jn7l0x6fd"

# Cookie
curl -b "auth_token=vaaopsz2qx1yo03jn7l0x6fd" https://example.com/api/data
```

### Token Management

| Operation | Endpoint | Description |
|-----------|----------|-------------|
| Create | `POST /auth/stored-tokens` | Create new token |
| List | `GET /auth/stored-tokens` | List user's tokens |
| Get | `GET /auth/stored-tokens/:id` | Get token metadata |
| Update | `PATCH /auth/stored-tokens/:id` | Edit name/note |
| Regenerate | `PUT /auth/stored-tokens/:id/jwt` | Replace JWT (change permissions/extend expiry) |
| Revoke | `DELETE /auth/stored-tokens/:id` | Mark as revoked |
| Purge | `DELETE /auth/stored-tokens/:id/purge` | Permanently delete (revoked only) |

**Regenerating a Token:**

The regenerate endpoint allows updating the JWT associated with a token ID without changing the ID itself. This is useful for:
- Extending token expiration
- Changing token permissions (upgrading or downgrading access)
- Rotating credentials while maintaining the same reference

```javascript
// Regenerate with new permissions
const newJwt = await acequia.tokens.createUserToken({
  paths: ['/new-path/*'],
  writePaths: []
}, '365d')

await fetch(`/auth/stored-tokens/${tokenId}/jwt`, {
  method: 'PUT',
  headers: { 'Authorization': `Bearer ${sessionToken}` },
  body: JSON.stringify({
    jwt: newJwt,
    expiresInDays: 365,
    scope: { type: 'custom', paths: ['/new-path/*'], writePaths: [] }
  })
})
```

### Scoped Tokens (Permission-Limited Tokens)

When creating a stored token, users can limit its permissions to less than their full access. This follows the **principle of least privilege** - tokens should only have the minimum permissions needed for their purpose.

**Permission Types:**

| Type | Description | Use Case |
|------|-------------|----------|
| **Full Access** | Same permissions as user account | General automation with full access |
| **Read Only** | Can read all paths, no write access | Backup scripts, monitoring |
| **Custom** | Specific read/write paths | Limited API integrations |

**Creating a Scoped Token (Client-side):**
```javascript
// Read-only token - can read everything, write nothing
const jwt = await acequia.tokens.createUserToken({
  paths: ['*'],
  writePaths: []
}, '365d')

// Custom paths - limited read and write
const jwt = await acequia.tokens.createUserToken({
  paths: ['/public/*', '/shared/*'],
  writePaths: ['/public/uploads/*']
}, '365d')
```

**Token Payload with Scope:**
```json
{
  "sub": "userId",
  "iss": "subdomain.example.com",
  "iat": 1707494400,
  "exp": 1739030400,
  "kid": "<JWK thumbprint>",
  "deviceId": "deviceId",
  "paths": ["/public/*", "/shared/*"],
  "writePaths": ["/public/uploads/*"]
}
```

**Authorization Behavior:**

For **owners**, the token scope claims restrict access:
- If `paths` is present, only those paths are readable
- If `writePaths` is present, only those paths are writable
- If `paths` is present but `writePaths` is absent, token is read-only

For **editors/viewers**, scope claims work alongside role permissions:
- User's existing role-based restrictions still apply
- Token claims cannot exceed user's granted permissions

**Stored Token with Scope Metadata:**
```json
{
  "tokenId": "vaaopsz2qx1yo03jn7l0x6fd",
  "userId": "owner_user_id",
  "jwt": "eyJhbGciOiJQUzI1NiJ9...",
  "name": "Read-Only Backup Token",
  "scope": {
    "type": "readonly",
    "paths": ["*"],
    "writePaths": []
  },
  "createdAt": "2024-02-09T10:30:00.000Z",
  "status": "active"
}
```

### Security Considerations

| Property | Implementation |
|----------|----------------|
| **Authorization** | Users can only manage their own tokens |
| **JWT signed by user** | Private key never leaves device |
| **JWT accessible to owner** | Users can view/copy their stored JWTs |
| **Scope enforcement** | Token claims limit access even for owners |
| **Revocation** | Revoked tokens return 401, kept for audit |
| **Expiration** | Optional; checked on each use |
| **Subdomain-scoped** | Tokens stored under `auth/{subdomain}/` |

---

## Service Worker Integration

The acequia service worker (`acequia2/sw.js`) handles token injection for WebDAV requests:

```javascript
if (credentials.type === 'acequiaToken') {
  let token = credentials.token
  if (!token) {
    // Create new device token if none exists
    token = await acequia.tokens.createDeviceToken({ roles: ['admin'] })
    credentials.token = token
    credentialsStore.setItem(matchedMount.serverUrl, credentials)
  }

  // Verify token before use
  let { payload } = await acequia.tokens.verifyDeviceToken(token)

  client = createClient(serverUrl, {
    authType: AuthType.Token,
    token: { token_type: 'Bearer', access_token: token },
  })
}
```

---

## Invite System

Subdomain owners can invite users via shareable links. Invites work for both **new users** and **existing users** whose permissions need updating.

### Invite Modes

Invites have a `mode` property that determines how permissions are applied to existing users:

| Mode | New Users | Existing Users |
|------|-----------|----------------|
| `replace` | Created with invite permissions | Permissions completely replaced |
| `merge` | Created with invite permissions | Paths merged, role only upgrades |

**Merge Mode Behavior:**
- Paths: Union of existing paths + invite paths
- WritePaths: Union of existing writePaths + invite writePaths
- Role: Only upgrades (viewer → editor), never downgrades (except from `pending`)

**Replace Mode Behavior:**
- Completely replaces role, paths, and writePaths
- Useful for demoting users or resetting permissions

### Creating an Invite

`POST /auth/invites` (Owner only):
```json
{
  "role": "editor",
  "mode": "replace",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "maxUses": 1,
  "expiresIn": 604800,
  "note": "For John Doe"
}
```

**Parameters:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `role` | string | Yes | - | `"editor"` or `"viewer"` |
| `mode` | string | No | `"replace"` | `"replace"` or `"merge"` |
| `paths` | array | Yes | - | Read access paths |
| `writePaths` | array | No | `paths` | Write access paths (editors only) |
| `maxUses` | number | No | `1` | Maximum number of uses |
| `expiresIn` | number | No | `604800` | Expiration in seconds (7 days) |
| `note` | string | No | `null` | Admin note |

**Response:**
```json
{
  "success": true,
  "inviteId": "abc123xyz",
  "inviteUrl": "https://subdomain.example.com:3334/accept-invite.html?invite=abc123xyz",
  "role": "editor",
  "mode": "replace",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "expiresAt": "2024-01-08T00:00:00Z",
  "maxUses": 1
}
```

### Invite Data Structure (`auth/{subdomain}/invites/{inviteId}.json`)

```json
{
  "inviteId": "abc123xyz",
  "subdomain": "example",
  "createdBy": "ownerUserId",
  "role": "editor",
  "mode": "replace",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "maxUses": 1,
  "uses": 0,
  "usedBy": [],
  "note": "For John Doe",
  "createdAt": "2024-01-01T00:00:00Z",
  "expiresAt": "2024-01-08T00:00:00Z",
  "status": "active"
}
```

### Getting Invite Info (Public)

`GET /auth/invites/:inviteId`:
```json
{
  "success": true,
  "inviteId": "abc123xyz",
  "subdomain": "example",
  "role": "editor",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "mode": "replace",
  "status": "active",
  "expiresAt": "2024-01-08T00:00:00Z",
  "remainingUses": 1
}
```

### Accepting an Invite

`POST /auth/invites/accept`:
```json
{
  "inviteId": "abc123xyz",
  "userId": "newUserId",
  "publicKey": { "kty": "RSA", "e": "AQAB", "n": "..." },
  "kid": "<JWK thumbprint>",
  "deviceId": "device1"
}
```

**Response (New User):**
```json
{
  "success": true,
  "message": "Invite accepted, user registered",
  "userId": "newUserId",
  "role": "editor",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "wasExistingUser": false,
  "previousPermissions": null,
  "mode": "replace"
}
```

**Response (Existing User - Replace Mode):**
```json
{
  "success": true,
  "message": "Invite accepted, permissions replaced",
  "userId": "existingUserId",
  "role": "editor",
  "paths": ["/shared/*"],
  "writePaths": ["/shared/*"],
  "wasExistingUser": true,
  "previousPermissions": {
    "role": "viewer",
    "paths": ["/public/*"],
    "writePaths": []
  },
  "mode": "replace"
}
```

**Response (Existing User - Merge Mode):**
```json
{
  "success": true,
  "message": "Invite accepted, permissions merged",
  "userId": "existingUserId",
  "role": "editor",
  "paths": ["/public/*", "/shared/*"],
  "writePaths": ["/shared/*"],
  "wasExistingUser": true,
  "previousPermissions": {
    "role": "viewer",
    "paths": ["/public/*"],
    "writePaths": []
  },
  "mode": "merge"
}
```

### Invite Status Values

| Status | Description |
|--------|-------------|
| `active` | Can be used |
| `expired` | Past expiration date |
| `exhausted` | Reached maximum uses |
| `revoked` | Manually revoked by owner |

### Accept Invite UI Flow (`/accept-invite.html`)

The acceptance page provides a visual flow for users:

1. **Load Invite** - Fetches invite details from API
2. **Check User Status** - Determines if user already exists on subdomain
3. **Display Preview** - For existing users, shows:
   - Current permissions (role, paths)
   - Mode explanation (merge vs replace)
   - Resulting permissions after acceptance
4. **Accept** - Sends acceptance request with user credentials
5. **Set Token** - Creates and stores auth token cookie

```
User clicks invite link
         │
         ▼
   Load invite details
         │
         ├── Invalid/expired? → Show error
         │
         ▼
   Check if user exists
         │
         ├── New user → Show "You're Invited!" + permissions
         │
         └── Existing user → Show "Update Your Access"
                   │
                   ├── Show mode explanation
                   ├── Show permission comparison (current → result)
                   └── Button: "Merge Permissions" or "Replace Permissions"
         │
         ▼
   User clicks Accept
         │
         ▼
   POST /auth/invites/accept
         │
         ▼
   Create & store auth token
         │
         ▼
   Redirect to subdomain root
```

### Listing Invites (Owner Only)

`GET /auth/invites`:
```json
{
  "success": true,
  "subdomain": "example",
  "invites": [
    {
      "inviteId": "abc123xyz",
      "role": "editor",
      "paths": ["/shared/*"],
      "writePaths": ["/shared/*"],
      "mode": "replace",
      "createdBy": "ownerUserId",
      "createdAt": "2024-01-01T00:00:00Z",
      "expiresAt": "2024-01-08T00:00:00Z",
      "status": "active",
      "uses": 0,
      "maxUses": 1,
      "note": "For John Doe"
    }
  ]
}
```

### Revoking an Invite (Owner Only)

`DELETE /auth/invites/:inviteId`:
```json
{
  "success": true,
  "message": "Invite revoked",
  "inviteId": "abc123xyz"
}
```

---

## API Endpoints

### User Registration & Key Management

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/users/register` | None | Register new user (first = owner, others = pending) |
| GET | `/auth/users/:userId` | Token | Get user info (own user or owner) |
| GET | `/auth/users/:userId/keys/:kid/public` | None | Get public key for verification |
| GET | `/auth/users/:userId/keys` | Token | List user's registered keys |
| POST | `/auth/users/:userId/keys` | Token | Add new device key |
| DELETE | `/auth/users/:userId/keys/:kid` | Token | Revoke a device key |
| DELETE | `/auth/users/:userId/keys/:kid/purge` | Token | Permanently delete a revoked key |

### Device Linking

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/device-link/short-code` | Token | Create short code from device link token |
| GET | `/auth/device-link/short-code/:code` | None | Look up token by short code |
| GET | `/auth/device-link/info` | None | Get info from device link token (without accepting) |
| POST | `/auth/device-link/accept` | None* | Accept device link token, add new key |

*Accept requires device link token + new device credentials in request body.

### Stored Tokens (API Tokens)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/stored-tokens` | Token | Create a new stored token |
| GET | `/auth/stored-tokens` | Token | List user's stored tokens |
| GET | `/auth/stored-tokens/:tokenId` | Token | Get single token metadata |
| PATCH | `/auth/stored-tokens/:tokenId` | Token | Update token name/note |
| DELETE | `/auth/stored-tokens/:tokenId` | Token | Revoke a token |
| DELETE | `/auth/stored-tokens/:tokenId/purge` | Token | Permanently delete a revoked token |

### User Management (Owner Only)

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/auth/users` | Owner | List all subdomain users |
| PATCH | `/auth/users/:userId` | Owner | Update user role/paths |
| DELETE | `/auth/users/:userId` | Owner | Remove user from subdomain |

**PATCH /auth/users/:userId Request:**
```json
{
  "role": "editor",
  "paths": ["/documents/*", "/shared/*"],
  "writePaths": ["/documents/*"]
}
```

### Invite Management

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/invites` | Owner | Create new invite |
| GET | `/auth/invites` | Owner | List all invites |
| GET | `/auth/invites/:inviteId` | None | Get invite info (public) |
| POST | `/auth/invites/accept` | None* | Accept invite (creates/updates user) |
| DELETE | `/auth/invites/:inviteId` | Owner | Revoke an invite |
| DELETE | `/auth/invites/:inviteId/purge` | Owner | Permanently delete a non-active invite |

*Accept requires user credentials (userId, publicKey, kid, deviceId) in request body.

### Site Administration

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/site/setup` | None | Initial site setup (first admin) |
| POST | `/api/subdomain/create` | Site Admin | Create new subdomain directory |

### UI Pages

| Path | Description |
|------|-------------|
| `/register-user.html` | Direct user registration page |
| `/login.html` | Device linking page for existing users |
| `/accept-invite.html?invite={id}` | Invite acceptance page |
| `/dashboard.html` | User dashboard with profile, devices, and admin tabs |
| `/owner-dashboard.html` | Legacy owner user/invite management |
| `/user-info.html` | User profile information |

### Dashboard Tabs (`/dashboard.html`)

| Tab | Description |
|-----|-------------|
| My Profile | User handle, auth token, profile settings |
| My Devices | Device management, link new device, revoke/purge keys |
| Subdomain Admin | Owner-only: user management, invites (visible only to owners) |

---

## Security Properties

| Property | Implementation |
|----------|----------------|
| **Algorithm** | PS256 (RSA-PSS with SHA-256) - industry standard |
| **Private Key Storage** | IndexedDB (browser, per-device) |
| **Private Key Export** | Never - keys generated and used in Web Crypto API |
| **Key Lifetime** | Permanent (survives browser sessions) |
| **Key Revocation** | Server-side via public key registry |
| **Token Expiration** | Optional, enforced server-side |
| **Token Signature** | RSA-PSS, verified with registered public key |
| **Key Attribution** | JWK thumbprint (`kid`) identifies exact signing key |
| **Subdomain Isolation** | Users in one subdomain cannot access another's resources |

---

## Migration from Legacy Authentication

Existing device-based authentication continues to work alongside the new user system. The authenticator detects the token type by checking for the `kid` claim:

- Tokens with `kid` → User authentication (`legacyAuth: false`)
- Tokens without `kid` → Legacy device authentication (`legacyAuth: true`)

The `legacyAuth` flag on the authenticated user object can be used to prompt users to migrate to the new user-based system.

---

## Token Flow Diagrams

### Token Creation (Client)

```
App Startup
     │
     ├──► ensureGUID('userId')     → IndexedDB
     ├──► ensureGUID('deviceId')   → IndexedDB
     ├──► ensureUserKeys()         → jose.generateKeyPair('PS256')
     │
     ▼
acequiaReady() resolves
     │
     ▼
createUserToken(payload)
     │
     ├──► calculateJwkThumbprint(userPublicJWK) → kid
     ├──► new SignJWT(payload)
     │       .setSubject(userId)
     │       .setProtectedHeader({ alg: 'PS256', kid })
     │
     └──► token.sign(userKeys.privateKey) → JWT string
```

### Token Verification (Server)

```
Request with JWT
     │
     ├──► Extract token (query > header > cookie)
     ├──► jose.decodeJwt(token) → { sub, kid, ... }
     │
     ├── kid present? ──► User Token Flow
     │       │
     │       ├──► loadUser(subdomain, userId)
     │       ├──► Find key by kid
     │       ├──► Check status !== 'revoked'
     │       ├──► jose.importJWK(publicKey)
     │       └──► jose.jwtVerify(token, publicKey)
     │
     └── kid absent? ──► Legacy Device Flow
             │
             ├──► Load device from devices/{subdomain}/{deviceId}.json
             └──► jose.jwtVerify(token, device.publicKey)
```
