Skip to main content

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. Contains sub (user id), permissions[], iat, exp, jti.
  • Refresh token — long-lived (30 days), opaque random (crypto.randomBytes(32).toString('base64url')), stored hashed server-side in a refresh_tokens collection 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 jti during the short window remaining.
  • Refresh tokens rotate on every use: POST /auth/refresh returns 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 + PathPurposeAuthRate limit
POST /auth/registerSelf-registration (email + username + password)public5/hour/ip
POST /auth/loginEmail + password → { accessToken, refreshToken, user }public10/hour/ip
POST /auth/refreshRotate refresh tokenpublic (refresh token is the credential)60/hour/ip
POST /auth/logoutRevoke refresh token familyauthenticated
GET /auth/meCurrent user + permissionsauthenticated
POST /auth/password/changeChange password; revokes all refresh tokensauthenticated5/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, populates request.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 checks request.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

PermissionGrants
content.submitCreate pending content (implicit for any authenticated user; listed for clarity)
content.moderateRead non-approved content (view pending queue)
content.approveApprove or reject pending content
content.deleteSoft-delete (set isActive=false) any content
tag.manageCreate / update / rename tags
catalog.manageCreate / update surfaces, groups, channels, platforms
user.manageCreate / update / deactivate users
user.inviteIssue invites when self-registration is closed
role.manageCreate / update roles and their permissions

Starting roles

Seeded at bootstrap if missing (see scripts/seed.ts — TBD):

  • admin — all permissions.
  • moderatorcontent.moderate, content.approve, tag.manage.
  • membercontent.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 passwordHash from 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_SECRET and JWT_REFRESH_SECRET are 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 tokenHash row already has revokedAt set 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.identity API (for Google / GitHub). Nice-to-have; not v1.
  • Per-user API tokens (for CLI or scripts without the extension). Out of scope until demand.