mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Harden the native auth-token surface against four confirmed findings. - /api/auth/logout was a no-op for a bearer credential: destroySession only clears the cookie, leaving an Authorization: Bearer hlk_ token live until expiry. When the request carries a bearer, also flip the matching ApiToken.revoked and revoke its paired refresh-token sibling. Cookie path unchanged. - A browser spoofing X-Client-Type: native was handed a 60-day refresh token in the JSON body. Gate refresh-token issuance on a new isCookielessNativeCaller check (no Mozilla UA, no inbound session cookie), so the secret reaches genuine cookie-less native callers only and never alongside a browser session. The iOS native path is unaffected; a spoofing browser falls back to the short-lived access token with no refresh. - Refresh-reuse revoke trusted the stored row's deviceId, so a stolen token replayed under a fabricated X-Device-Id confined the revoke to the attacker's own id and left the victim's family live. On a present-but- mismatched deviceId, escalate to the user-wide family revoke (a real device never changes its id mid-family). The matching-id and null-id cases keep their existing scope. - Tighten the auth:refresh rate limit from 60/15min to 10/15min, aligning the high-value rotation endpoint with the passkey-verify tier. Add coverage for each: logout revokes bearer + sibling, a cookie/Mozilla caller never receives a refresh token, a device-mismatch replay triggers the wide revoke, and the cookie-less native path still gets its refresh token. (cherry picked from commit 14dcdefc5f40855305d8aaf573d073faebc61476)
278 lines
9.5 KiB
TypeScript
278 lines
9.5 KiB
TypeScript
/**
|
|
* Refresh-token issuance + rotation (v1.4 G4).
|
|
*
|
|
* One-time-use semantics: every successful refresh marks the consumed
|
|
* row's `usedAt`, sets `replacedById` to the new row, and revokes the
|
|
* paired access token. Reuse of an already-consumed token is treated as
|
|
* a stolen-token signal and revokes the entire token family (caller must
|
|
* log in again).
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
import { prisma } from "@/lib/db";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { issueApiToken } from "@/lib/auth/issue-token";
|
|
import type { TokenPolicyDecision } from "@/lib/auth/native-client";
|
|
|
|
export interface IssuedRefreshBundle {
|
|
accessToken: string;
|
|
accessTokenExpiresAt: Date;
|
|
refreshToken: string;
|
|
refreshTokenExpiresAt: Date;
|
|
}
|
|
|
|
export interface IssueRefreshOpts {
|
|
userId: string;
|
|
policy: TokenPolicyDecision;
|
|
deviceId?: string | null;
|
|
userAgent?: string | null;
|
|
ipAddress?: string | null;
|
|
/** Token name suffix for the underlying ApiToken row. */
|
|
source: string;
|
|
}
|
|
|
|
function generateRefreshTokenSecret(): string {
|
|
return `hlr_${randomBytes(32).toString("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* Issue a fresh access token + refresh token pair. Used by:
|
|
* - login (password + passkey verify) for native callers
|
|
* - the /api/auth/refresh rotation endpoint
|
|
*/
|
|
export async function issueAccessAndRefresh(
|
|
opts: IssueRefreshOpts,
|
|
): Promise<IssuedRefreshBundle> {
|
|
if (opts.policy.refreshTokenDays === null) {
|
|
throw new Error(
|
|
"issueAccessAndRefresh called for web policy (no refresh token)",
|
|
);
|
|
}
|
|
|
|
const access = await issueApiToken({
|
|
userId: opts.userId,
|
|
name: `${opts.policy.tokenLabel} ${opts.source} ${new Date().toISOString()}`,
|
|
permissions: ["*"],
|
|
expiresInDays: opts.policy.accessTokenDays,
|
|
});
|
|
|
|
const refresh = generateRefreshTokenSecret();
|
|
const refreshHash = hashToken(refresh);
|
|
const accessTokenHash = hashToken(access.token);
|
|
const expiresAt = new Date(
|
|
Date.now() + opts.policy.refreshTokenDays * 24 * 60 * 60 * 1000,
|
|
);
|
|
|
|
await prisma.refreshToken.create({
|
|
data: {
|
|
userId: opts.userId,
|
|
tokenHash: refreshHash,
|
|
accessTokenHash,
|
|
deviceId: opts.deviceId ?? null,
|
|
expiresAt,
|
|
userAgent: opts.userAgent ?? null,
|
|
ipAddress: opts.ipAddress ?? null,
|
|
},
|
|
});
|
|
|
|
return {
|
|
accessToken: access.token,
|
|
accessTokenExpiresAt: access.expiresAt,
|
|
refreshToken: refresh,
|
|
refreshTokenExpiresAt: expiresAt,
|
|
};
|
|
}
|
|
|
|
export type RotationFailureReason =
|
|
| "not_found"
|
|
| "expired"
|
|
| "already_used"
|
|
| "revoked";
|
|
|
|
export type RotationResult =
|
|
| { ok: true; bundle: IssuedRefreshBundle }
|
|
| { ok: false; reason: RotationFailureReason };
|
|
|
|
/**
|
|
* Atomically rotate a refresh token: validate, mark consumed, issue a new
|
|
* pair, revoke the previously-paired access token. Reuse of a consumed
|
|
* token revokes the whole family (defence against stolen refresh tokens).
|
|
*/
|
|
export async function rotateRefreshToken(input: {
|
|
refreshToken: string;
|
|
policy: TokenPolicyDecision;
|
|
deviceId?: string | null;
|
|
userAgent?: string | null;
|
|
ipAddress?: string | null;
|
|
}): Promise<RotationResult> {
|
|
const hash = hashToken(input.refreshToken);
|
|
const row = await prisma.refreshToken.findUnique({
|
|
where: { tokenHash: hash },
|
|
});
|
|
|
|
if (!row) return { ok: false, reason: "not_found" };
|
|
if (row.revokedAt) return { ok: false, reason: "revoked" };
|
|
if (row.expiresAt.getTime() <= Date.now())
|
|
return { ok: false, reason: "expired" };
|
|
|
|
if (row.usedAt) {
|
|
// Reuse-detection: a previously-consumed refresh token shouldn't be
|
|
// presented again. v1.4.23 scopes the blast radius to the originating
|
|
// device so the legitimate two-device case keeps working — an iPhone
|
|
// dropping into airplane mode shouldn't sign the user out of their
|
|
// iPad. Tokens issued before v1.4.23 (deviceId === null) still
|
|
// revoke-user-wide because we can't isolate them safely.
|
|
//
|
|
// Defence-in-depth note: the iOS client always sends X-Device-Id on
|
|
// refresh, so the legitimate path always carries a deviceId. A
|
|
// missing deviceId on a replay is itself a suspicious signal, hence
|
|
// the fall-through to the wider revoke.
|
|
//
|
|
// M-4 hardening: a legitimate device never changes its id mid-family.
|
|
// If the *presented* deviceId is present but differs from the stored
|
|
// row's deviceId, the device-scoped key is client-asserted and would
|
|
// confine the revoke to the attacker's own fabricated id, leaving the
|
|
// victim's family live. Escalate to the user-wide revoke in that case
|
|
// so a stolen token replayed under a spoofed id can't dodge
|
|
// containment.
|
|
const presentedDeviceId = input.deviceId ?? null;
|
|
const storedDeviceId = row.deviceId ?? null;
|
|
const deviceMismatch =
|
|
presentedDeviceId !== null &&
|
|
storedDeviceId !== null &&
|
|
presentedDeviceId !== storedDeviceId;
|
|
const where: { userId: string; revokedAt: null; deviceId?: string } =
|
|
storedDeviceId !== null && !deviceMismatch
|
|
? { userId: row.userId, revokedAt: null, deviceId: storedDeviceId }
|
|
: { userId: row.userId, revokedAt: null };
|
|
const compromised = await prisma.refreshToken.findMany({ where });
|
|
await prisma.refreshToken.updateMany({
|
|
where,
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
const accessHashes = compromised
|
|
.map((c) => c.accessTokenHash)
|
|
.filter((v): v is string => Boolean(v));
|
|
if (accessHashes.length > 0) {
|
|
await prisma.apiToken.updateMany({
|
|
where: { tokenHash: { in: accessHashes } },
|
|
data: { revoked: true },
|
|
});
|
|
}
|
|
return { ok: false, reason: "already_used" };
|
|
}
|
|
|
|
// Issue the new pair first, THEN mark old consumed in a transaction.
|
|
// (Race: if two concurrent refreshes hit the same row, both will try to
|
|
// mark `usedAt`. We use updateMany with a `usedAt: null` guard so only
|
|
// one wins; the loser's new token row is orphaned but harmless because
|
|
// the loser's access token will be revoked alongside it.)
|
|
const bundle = await issueAccessAndRefresh({
|
|
userId: row.userId,
|
|
policy: input.policy,
|
|
deviceId: row.deviceId ?? input.deviceId ?? null,
|
|
userAgent: input.userAgent ?? row.userAgent,
|
|
ipAddress: input.ipAddress ?? row.ipAddress,
|
|
source: "refresh",
|
|
});
|
|
|
|
// Find the row we just created so we can store its id as replacedById.
|
|
const newHash = hashToken(bundle.refreshToken);
|
|
const newRow = await prisma.refreshToken.findUnique({
|
|
where: { tokenHash: newHash },
|
|
select: { id: true },
|
|
});
|
|
|
|
const updated = await prisma.refreshToken.updateMany({
|
|
where: { id: row.id, usedAt: null },
|
|
data: {
|
|
usedAt: new Date(),
|
|
replacedById: newRow?.id ?? null,
|
|
},
|
|
});
|
|
|
|
if (updated.count === 0) {
|
|
// Lost the race — another concurrent refresh consumed this row.
|
|
// Revoke our just-issued tokens to avoid leaking an extra valid pair.
|
|
await prisma.refreshToken.updateMany({
|
|
where: { tokenHash: newHash },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
await prisma.apiToken.updateMany({
|
|
where: { tokenHash: hashToken(bundle.accessToken) },
|
|
data: { revoked: true },
|
|
});
|
|
return { ok: false, reason: "already_used" };
|
|
}
|
|
|
|
// Best-effort: revoke the access token paired with the consumed refresh,
|
|
// so any leaked access token can't outlive its refresh-token sibling.
|
|
if (row.accessTokenHash) {
|
|
await prisma.apiToken.updateMany({
|
|
where: { tokenHash: row.accessTokenHash, revoked: false },
|
|
data: { revoked: true },
|
|
});
|
|
}
|
|
|
|
return { ok: true, bundle };
|
|
}
|
|
|
|
/**
|
|
* Revoke a bearer access token presented at logout (M-2 hardening).
|
|
*
|
|
* `destroySession()` only clears the cookie; for a native/bearer transport
|
|
* that does not round-trip the refresh endpoint, `/api/auth/logout` would
|
|
* otherwise leave the access token valid until expiry. This hashes the raw
|
|
* `hlk_<…>` token, flips the matching `ApiToken.revoked = true`, and
|
|
* revokes its paired `RefreshToken` sibling (matched by `accessTokenHash`)
|
|
* so the whole credential pair dies with the logout.
|
|
*
|
|
* Returns true when a matching live ApiToken row was revoked.
|
|
*/
|
|
export async function revokeBearerAccessToken(
|
|
rawAccessToken: string,
|
|
): Promise<boolean> {
|
|
const accessHash = hashToken(rawAccessToken);
|
|
|
|
const apiResult = await prisma.apiToken.updateMany({
|
|
where: { tokenHash: accessHash, revoked: false },
|
|
data: { revoked: true },
|
|
});
|
|
|
|
// Revoke the paired refresh sibling so a native logout kills both halves
|
|
// of the pair, mirroring the rotation-time access-token revoke.
|
|
await prisma.refreshToken.updateMany({
|
|
where: { accessTokenHash: accessHash, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
|
|
return apiResult.count > 0;
|
|
}
|
|
|
|
/** Revoke a specific refresh token (logout-on-device).
|
|
* Also revokes the paired access token so a leaked access token cannot
|
|
* outlive the refresh-token sibling that the user just killed. */
|
|
export async function revokeRefreshToken(
|
|
refreshToken: string,
|
|
): Promise<boolean> {
|
|
const hash = hashToken(refreshToken);
|
|
const row = await prisma.refreshToken.findUnique({
|
|
where: { tokenHash: hash },
|
|
select: { accessTokenHash: true, revokedAt: true },
|
|
});
|
|
if (!row || row.revokedAt) return false;
|
|
|
|
const result = await prisma.refreshToken.updateMany({
|
|
where: { tokenHash: hash, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
if (result.count === 0) return false;
|
|
|
|
if (row.accessTokenHash) {
|
|
await prisma.apiToken.updateMany({
|
|
where: { tokenHash: row.accessTokenHash, revoked: false },
|
|
data: { revoked: true },
|
|
});
|
|
}
|
|
return true;
|
|
}
|