Authentication & Authorization
This document specifies how clients authenticate against the API and how write/admin operations are gated. The primary client is a Chrome MV3 extension; that shapes several decisions below. Endpoint list and response envelope live in API.md.
1. Scheme: JWT + refresh tokens
- Access token — short-lived (15 minutes), JWT, signed HS256 with
JWT_ACCESS_SECRET. Containssub(user id),permissions[],iat,exp,jti. - Refresh token — long-lived (30 days), opaque random (
crypto.randomBytes(32).toString('base64url')), stored hashed server-side in arefresh_tokenscollection with{ userId, tokenHash, expiresAt, revokedAt, createdUserAgent, createdIp }. - Access tokens are not stored server-side (stateless). Revocation is handled by rotating the refresh token family and refusing any older
jtiduring the short window remaining. - Refresh tokens rotate on every use:
POST /auth/refreshreturns a new refresh token and invalidates the old one. Reuse of an already-rotated refresh token revokes the entire family and forces re-login (classic token-theft detection).
Why not sessions / cookies? Chrome extensions can't rely on cookies from a different origin without host_permissions gymnastics, and MV3 service workers suspend — so cookie-bearing auto-requests don't work cleanly. Bearer tokens in Authorization headers are portable, inspectable, and unambiguous.
2. Chrome-extension storage and usage
Where tokens live
chrome.storage.local— accessible to background service worker, popup, side panel, options page. Not accessible from content scripts.- Content scripts never see tokens. If a content script needs to act on behalf of the user, it sends a message (
chrome.runtime.sendMessage) to the background service worker, which attaches the token and issues the request.
Why not chrome.storage.session?
storage.session clears on browser restart. Users would re-log-in every morning. storage.local persists; the refresh-token rotation policy handles the security tradeoff.
Service-worker lifecycle
MV3 service workers suspend after ~30 seconds idle. Treat the worker as stateless:
- Every outbound API request reads the access token fresh from
chrome.storage.local. - On 401, the worker calls
/auth/refresh, writes the new tokens back, and retries the original request once. - On 401 after refresh, the worker clears tokens and posts a message so UI surfaces route to login.
Pseudocode (extension side):
async function authorizedFetch(path: string, init: RequestInit = {}) {
let { accessToken, refreshToken } = await chrome.storage.local.get(['accessToken', 'refreshToken']);
const go = (tok: string) => fetch(`${API_BASE}${path}`, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${tok}` },
});
let res = await go(accessToken);
if (res.status !== 401) return res;
// Refresh once and retry
const r = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!r.ok) { await chrome.storage.local.remove(['accessToken', 'refreshToken']); throw new NeedsLogin(); }
const tokens = (await r.json()).data;
await chrome.storage.local.set(tokens);
return go(tokens.accessToken);
}
CORS
The API must accept the extension's origin. Configure via env (CORS_ALLOWED_ORIGINS, comma-separated):
- Development:
chrome-extension://<dev-id>(the unpacked extension ID varies per machine — include multiple or use a wildcard flag in dev only). - Production:
chrome-extension://<web-store-id>once published.
The API never sets credential cookies, so Access-Control-Allow-Credentials: false is fine. Include Authorization in Access-Control-Allow-Headers.
If a public web reader is added later, its origin joins the allowed list.
3. Endpoints
| Method + Path | Purpose | Auth | Rate limit |
|---|---|---|---|
POST /auth/register | Self-registration (email + username + password) | public | 5/hour/ip |
POST /auth/login | Email + password → { accessToken, refreshToken, user } | public | 10/hour/ip |
POST /auth/refresh | Rotate refresh token | public (refresh token is the credential) | 60/hour/ip |
POST /auth/logout | Revoke refresh token family | authenticated | — |
GET /auth/me | Current user + permissions | authenticated | — |
POST /auth/password/change | Change password; revokes all refresh tokens | authenticated | 5/hour/user |
Bodies follow API.md §1 envelope conventions. Responses carry data.accessToken, data.refreshToken, and data.user where applicable.
Registration gating. If the product should be invite-only, set AUTH_ALLOW_SELF_REGISTRATION=false; the endpoint then returns 403 unless the caller has user.invite. Default in production: false.
4. Guards (NestJS)
JwtAuthGuard— extracts bearer token, verifies signature + expiry, populatesrequest.user = { id, permissions[] }.OptionalJwtAuthGuard— same, but does not 401 on missing token. Used on list endpoints where anonymous access is permitted but authenticated users see more (e.g.status=pending).PermissionsGuard— reads@RequiredPermissions('x', 'y')decorator metadata and checksrequest.user.permissions. AND semantics (all required permissions must be present).
Guard application:
@Controller('content')
export class ContentController {
@Get() // public, anonymous = approved-only
@UseGuards(OptionalJwtAuthGuard)
list() { /* ... */ }
@Post('submit') // authenticated
@UseGuards(JwtAuthGuard)
submit() { /* ... */ }
@Post(':slug/approve') // moderator
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequiredPermissions('content.approve')
approve() { /* ... */ }
}
Parameter decorator
@CurrentUser() user: AuthenticatedUser // typed { id, permissions }
5. Permissions
Permissions are flat strings; roles are collections of permissions. Guards check permissions, not roles — this makes new permissions additive without modeling "is this role X" branches.
Starting set
| Permission | Grants |
|---|---|
content.submit | Create pending content (implicit for any authenticated user; listed for clarity) |
content.moderate | Read non-approved content (view pending queue) |
content.approve | Approve or reject pending content |
content.delete | Soft-delete (set isActive=false) any content |
tag.manage | Create / update / rename tags |
catalog.manage | Create / update surfaces, groups, channels, platforms |
user.manage | Create / update / deactivate users |
user.invite | Issue invites when self-registration is closed |
role.manage | Create / update roles and their permissions |
Starting roles
Seeded at bootstrap if missing (see scripts/seed.ts — TBD):
admin— all permissions.moderator—content.moderate,content.approve,tag.manage.member—content.submit(the default for new sign-ups).
Roles are data, not code. Operators can add or edit roles through the admin surface without a redeploy.
6. Password handling
- Hash:
argon2id,memoryCost=65536,timeCost=3,parallelism=1(OWASP 2024 baseline; revisit yearly). - Verification uses constant-time comparison via the argon2 library — don't hand-roll.
- Minimum length 12; no composition rules. Reject passwords found in a compiled breach list (optional v2; not blocking v1).
- Never log passwords or hashes; redact
passwordHashfrom every response DTO.
Current schema already has passwordHash on User. Keep the field name; it documents intent.
7. Rate limiting
@nestjs/throttler applied globally with per-route overrides. Keyed by IP for pre-auth endpoints, by user id for authenticated ones. Limits in §3 are starting points.
On exceedance: 429 with Retry-After header (seconds) and error.code = "ratelimit.exceeded".
Do not apply throttling to the catalog or /meta endpoints — they're cached aggressively and need to serve the extension's cold-start burst.
8. Security notes
JWT_ACCESS_SECRETandJWT_REFRESH_SECRETare 32+ bytes of random; separate secrets so token families are independent.- Access-token payload is readable (JWT is base64, not encrypted) — never put anything sensitive in claims.
- Token invalidation on password change: delete all refresh tokens for the user; short-lived access tokens expire naturally.
- Refresh-token rotation detects reuse: if a
tokenHashrow already hasrevokedAtset and a client presents it, revoke the entire family for that user (userId). This catches the "attacker stole a refresh token, user's subsequent refresh triggers detection" case. - On logout, revoke the presented refresh token's family — not the single row — so partially-replayed families are closed too.
- HTTPS is assumed in production. In dev (
http://localhost:4100) we tolerate cleartext; the extension must connect to HTTPS in prod regardless of CORS. - No
/auth/*endpoint returns the existence or non-existence of an email distinctly — login failures always return the same generic error to prevent enumeration.
9. Open questions (explicit)
These are not decided; revisit before building:
- Email verification on registration (recommended, adds a workflow).
- 2FA (TOTP via an authenticator app — fits Chrome-extension flow cleanly).
- SSO via Chrome's
chrome.identityAPI (for Google / GitHub). Nice-to-have; not v1. - Per-user API tokens (for CLI or scripts without the extension). Out of scope until demand.