Files
HealthLog/src/lib/auth/__tests__/refresh-token.test.ts
T
Marc-André BombeckandMarc-André Bombeck d7778c1085 feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI

Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.

scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.

Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens

Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.

New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.

Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.

Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE

Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.

docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.

docs/self-hosting/scaling.md walks through when + how to split.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage

New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.

Wire format documented in docs/ops/backup-restore.md:
  magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext

The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.

Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.

scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.

Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates

Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:

Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
  (column doesn't exist in the schema; the script crashed on the AppSettings
  step). Add the three encrypted columns it was missing —
  NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
  — so rotating to a new key actually re-encrypts every encrypted row.
  Without this, dropping the old key id from ENCRYPTION_KEYS would leave
  notification channels and web-push subscriptions permanently
  undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
  legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
  a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
  and run scripts/rotate-encryption-key.ts before removing it" so an
  operator who pulled the v1 too early notices immediately instead of
  hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
  troubleshooting section. Spells out that rotation is a forward-only
  operation and what to do if the rollback path is needed.

Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
  X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
  The worker container (which still runs `node server.js` for now) no
  longer accepts requests, so a misrouted client gets a clear hint instead
  of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
  worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
  "[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
  silently 302-redirect through the auth gate (the prior wget treated the
  login page as a 200 success and would never notice an unhealthy backend).

Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
  refresh tokens for the user, not only those scoped to the same deviceId.
  An attacker who rotated without an X-Device-Id header would otherwise
  keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
  access token cannot outlive the refresh-token sibling that the caller
  just killed (logout-on-device).

Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
  Retention is the bucket's lifecycle-policy job; the worker keeps a
  PutObject + GetObject grant only. A compromised worker can no longer
  wipe the backup history. Per-user errors are now surfaced as Wide
  Event warnings + a structured failures[] array on the report so an
  operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
  writing to disk, plus an optional --user-id=<id> flag that refuses to
  proceed unless the dump's userId matches. Prevents silent
  cross-tenant restores when an operator points the script at the wrong
  S3 key.

Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
  (access-token prefix); also block `hlr_` so a future caller wrapping
  the new /api/auth/refresh endpoint with withIdempotency() can't
  accidentally cache a freshly-issued refresh token.

Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
  transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
  partial-deploy retry is a no-op instead of "relation already exists";
  guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
  composite `@@index([userId, deviceId])` (every lookup also filters on
  userId; the old index would never have been used). Comment on
  accessTokenHash corrected to HMAC-SHA-256.

Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
  pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
  Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
  the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
  BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
  (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
  BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
  comments and the all-or-none semantics for the backup block.

491 tests still green, typecheck clean.

* chore: retrigger CI

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 14:33:39 +02:00

270 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock prisma + the underlying issueApiToken so we can assert flow
// without spinning up a database.
const dbState: {
refreshTokens: Array<{
id: string;
userId: string;
tokenHash: string;
deviceId: string | null;
accessTokenHash: string | null;
expiresAt: Date;
revokedAt: Date | null;
usedAt: Date | null;
replacedById: string | null;
userAgent: string | null;
ipAddress: string | null;
createdAt: Date;
}>;
apiTokens: Array<{ tokenHash: string; revoked: boolean }>;
} = { refreshTokens: [], apiTokens: [] };
let issuedCounter = 0;
vi.mock("@/lib/db", () => ({
prisma: {
refreshToken: {
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = {
id: `rt_${++issuedCounter}`,
createdAt: new Date(),
revokedAt: null,
usedAt: null,
replacedById: null,
userAgent: null,
ipAddress: null,
deviceId: null,
accessTokenHash: null,
...data,
} as (typeof dbState.refreshTokens)[number];
dbState.refreshTokens.push(row);
return row;
}),
findUnique: vi.fn(async ({ where }: { where: { tokenHash: string } }) => {
return (
dbState.refreshTokens.find((r) => r.tokenHash === where.tokenHash) ??
null
);
}),
findMany: vi.fn(async ({ where }: { where: Record<string, unknown> }) => {
return dbState.refreshTokens.filter((r) =>
Object.entries(where).every(
([k, v]) => (r as unknown as Record<string, unknown>)[k] === v,
),
);
}),
updateMany: vi.fn(
async ({
where,
data,
}: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => {
let count = 0;
for (const row of dbState.refreshTokens) {
const matches = Object.entries(where).every(
([k, v]) => (row as unknown as Record<string, unknown>)[k] === v,
);
if (matches) {
Object.assign(row, data);
count++;
}
}
return { count };
},
),
},
apiToken: {
updateMany: vi.fn(
async ({
where,
data,
}: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => {
let count = 0;
for (const row of dbState.apiTokens) {
const tokenHashFilter = where.tokenHash as
| string
| { in: string[] }
| undefined;
const matches =
!tokenHashFilter ||
(typeof tokenHashFilter === "string"
? row.tokenHash === tokenHashFilter
: tokenHashFilter.in.includes(row.tokenHash));
if (matches) {
Object.assign(row, data);
count++;
}
}
return { count };
},
),
},
},
}));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(
async (opts: { userId: string; expiresInDays?: number }) => {
issuedCounter++;
const token = `hlk_token_${issuedCounter}`;
dbState.apiTokens.push({
tokenHash: `hash:${token}`,
revoked: false,
});
return {
token,
expiresAt: new Date(Date.now() + (opts.expiresInDays ?? 1) * 86400000),
tokenId: `t_${issuedCounter}`,
name: "test",
};
},
),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: (raw: string) => `hash:${raw}`,
}));
import {
issueAccessAndRefresh,
rotateRefreshToken,
revokeRefreshToken,
} from "../refresh-token";
import type { TokenPolicyDecision } from "../native-client";
const NATIVE_POLICY: TokenPolicyDecision = {
policy: "native",
accessTokenDays: 1,
refreshTokenDays: 60,
tokenLabel: "native",
};
beforeEach(() => {
dbState.refreshTokens = [];
dbState.apiTokens = [];
issuedCounter = 0;
});
describe("issueAccessAndRefresh", () => {
it("creates an ApiToken + RefreshToken row paired by hash", async () => {
const bundle = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
expect(bundle.accessToken).toMatch(/^hlk_token_/);
expect(bundle.refreshToken).toMatch(/^hlr_/);
expect(dbState.refreshTokens).toHaveLength(1);
expect(dbState.refreshTokens[0].accessTokenHash).toBe(
`hash:${bundle.accessToken}`,
);
});
it("throws if called for web policy (no refresh token)", async () => {
await expect(
issueAccessAndRefresh({
userId: "u1",
policy: { ...NATIVE_POLICY, refreshTokenDays: null, policy: "web" },
source: "login.password",
}),
).rejects.toThrow(/web policy/);
});
});
describe("rotateRefreshToken", () => {
it("rotates: marks old used, issues new pair, revokes old access token", async () => {
const initial = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
const oldHash = `hash:${initial.accessToken}`;
expect(
dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked,
).toBe(false);
const result = await rotateRefreshToken({
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const oldRow = dbState.refreshTokens[0];
expect(oldRow.usedAt).not.toBeNull();
expect(oldRow.replacedById).not.toBeNull();
expect(
dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked,
).toBe(true);
expect(dbState.refreshTokens).toHaveLength(2);
});
it("rejects + revokes family on reuse of consumed refresh token", async () => {
const initial = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
deviceId: "dev-1",
});
const first = await rotateRefreshToken({
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
deviceId: "dev-1",
});
expect(first.ok).toBe(true);
// Replay the original token — must be rejected as `already_used` AND
// the replacement family must be revoked too (defence in depth).
const replay = await rotateRefreshToken({
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
deviceId: "dev-1",
});
expect(replay.ok).toBe(false);
if (replay.ok) return;
expect(replay.reason).toBe("already_used");
// All non-revoked refresh rows should now be revoked
const live = dbState.refreshTokens.filter((r) => r.revokedAt === null);
expect(live).toHaveLength(0);
});
it("rejects expired refresh tokens", async () => {
await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
// Manually expire
dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000);
const result = await rotateRefreshToken({
refreshToken: "hlr_doesnt_matter",
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("not_found");
});
it("rejects revoked refresh tokens", async () => {
const initial = await issueAccessAndRefresh({
userId: "u1",
policy: NATIVE_POLICY,
source: "login.password",
});
await revokeRefreshToken(initial.refreshToken);
const result = await rotateRefreshToken({
refreshToken: initial.refreshToken,
policy: NATIVE_POLICY,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("revoked");
});
});