This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createOAuthState, verifyOAuthState } from "../oauth-state";
describe("oauth-state", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "test-auth-secret-not-for-production";
});
it("verifies a state token for the staff member it was issued to", () => {
const state = createOAuthState("staff-123");
expect(verifyOAuthState(state, "staff-123")).toBe(true);
});
it("rejects a state token issued to a different staff member", () => {
const state = createOAuthState("staff-123");
expect(verifyOAuthState(state, "staff-999")).toBe(false);
});
it("rejects a tampered token", () => {
const state = createOAuthState("staff-123");
const tampered = `${state.slice(0, -2)}xx`;
expect(verifyOAuthState(tampered, "staff-123")).toBe(false);
});
it("rejects a malformed token", () => {
expect(verifyOAuthState("not-a-real-token", "staff-123")).toBe(false);
expect(verifyOAuthState("", "staff-123")).toBe(false);
});
it("rejects an expired token", () => {
vi.useFakeTimers();
const state = createOAuthState("staff-123");
vi.advanceTimersByTime(11 * 60 * 1000); // past the 10-minute max age
expect(verifyOAuthState(state, "staff-123")).toBe(false);
vi.useRealTimers();
});
});
@@ -0,0 +1,34 @@
import { beforeEach, describe, expect, it } from "vitest";
import { decryptToken, encryptToken } from "../token-encryption";
describe("token-encryption", () => {
beforeEach(() => {
process.env.CALENDAR_TOKEN_ENCRYPTION_KEY = "test-key-not-for-production-use";
});
it("round-trips a plaintext value", () => {
const plaintext = "1//0gExampleGoogleRefreshToken";
const encrypted = encryptToken(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(decryptToken(encrypted)).toBe(plaintext);
});
it("produces a different ciphertext for the same plaintext each time (random IV)", () => {
const a = encryptToken("same-value");
const b = encryptToken("same-value");
expect(a).not.toBe(b);
expect(decryptToken(a)).toBe("same-value");
expect(decryptToken(b)).toBe("same-value");
});
it("throws if CALENDAR_TOKEN_ENCRYPTION_KEY is not set", () => {
delete process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
expect(() => encryptToken("x")).toThrow(/CALENDAR_TOKEN_ENCRYPTION_KEY/);
});
it("fails to decrypt with a tampered ciphertext (authentication tag mismatch)", () => {
const encrypted = encryptToken("secret-value");
const tampered = encrypted.slice(0, -4) + "AAAA";
expect(() => decryptToken(tampered)).toThrow();
});
});
+65
View File
@@ -0,0 +1,65 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Signed, short-lived OAuth `state` for the Google Calendar connect flow
* (see src/app/api/staff/calendar/google/{authorize,callback}/route.ts).
* Same HMAC-over-JSON approach as src/lib/booking-draft/sign.ts, with its
* own context string — kept as a separate module rather than reusing that
* one directly, so the two signing keys stay cryptographically
* independent (a leaked booking-draft signature should tell an attacker
* nothing about this one, and vice versa).
*/
const CONTEXT = "nextchair-calendar-oauth-state-v1";
const MAX_AGE_MS = 10 * 60 * 1000;
interface StatePayload {
nonce: string;
staffId: string;
issuedAt: number;
}
function getSigningKey(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret) throw new Error("AUTH_SECRET must be set to sign the calendar OAuth state.");
return createHmac("sha256", secret).update(CONTEXT).digest();
}
export function createOAuthState(staffId: string): string {
const payload: StatePayload = { nonce: randomBytes(16).toString("hex"), staffId, issuedAt: Date.now() };
const json = JSON.stringify(payload);
const mac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
return `${Buffer.from(json, "utf8").toString("base64url")}.${mac}`;
}
/** Returns the staffId if `token` is a validly-signed, non-expired state for this exact staff member — null otherwise. Never throws. */
export function verifyOAuthState(token: string, expectedStaffId: string): boolean {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return false;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return false;
let json: string;
try {
json = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return false;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(json).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) return false;
let payload: StatePayload;
try {
payload = JSON.parse(json);
} catch {
return false;
}
if (payload.staffId !== expectedStaffId) return false;
if (Date.now() - payload.issuedAt > MAX_AGE_MS) return false;
return true;
}
+17
View File
@@ -0,0 +1,17 @@
import { timingSafeEqual } from "node:crypto";
/**
* Constant-time string comparison for secrets (cron bearer tokens, the
* installation token) — a plain `!==` leaks a secret's length and correct
* prefix via response timing. Shared by
* src/app/api/cron/process-notifications/route.ts and
* src/features/installation/actions/install.ts; see also
* src/lib/booking-draft/sign.ts, which uses the same primitive directly
* for HMAC comparison rather than through this helper.
*/
export function secretsMatch(provided: string, expected: string): boolean {
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(expected);
if (providedBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(providedBuffer, expectedBuffer);
}
+47
View File
@@ -0,0 +1,47 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
/**
* AES-256-GCM at-rest encryption for Google Calendar OAuth tokens (see
* StaffCalendarConnection in prisma/schema.prisma) — refresh tokens are
* long-lived credentials for a third-party account and must never be
* stored in plain text. Output format: base64(iv[12] + authTag[16] +
* ciphertext) — everything needed to decrypt in one column, no separate
* iv/tag columns to keep in sync.
*
* CALENDAR_TOKEN_ENCRYPTION_KEY is expected to be a 32-byte value,
* hex-encoded (64 hex chars) — generate with `openssl rand -hex 32`. A
* SHA-256 of the raw env value is used as the actual AES key (not the
* raw bytes directly) so a key of any length/format still yields a
* valid 32-byte AES-256 key, rather than throwing at startup for a
* slightly-wrong-length env var.
*/
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.CALENDAR_TOKEN_ENCRYPTION_KEY;
if (!secret) {
throw new Error("CALENDAR_TOKEN_ENCRYPTION_KEY must be set to store Google Calendar tokens.");
}
return createHash("sha256").update(secret).digest();
}
export function encryptToken(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
}
export function decryptToken(encoded: string): string {
const raw = Buffer.from(encoded, "base64");
const iv = raw.subarray(0, IV_LENGTH);
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}