Phase 8
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user