diff --git a/components/dashboard/account/SubscriptionSettings.tsx b/components/dashboard/account/SubscriptionSettings.tsx index 72c77d1a..31dfc42a 100644 --- a/components/dashboard/account/SubscriptionSettings.tsx +++ b/components/dashboard/account/SubscriptionSettings.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { ArrowRight, Check, Lock } from "lucide-react"; import { isTauri } from "@tauri-apps/api/core"; -import { cancelStripeSubscription, createStripeCheckout, verifyApplePurchase } from "@src/lib/utils/requests"; +import { cancelStripeSubscription, createStripeCheckout, submitApplePurchase } from "@src/lib/utils/requests"; import { useUser } from "@src/lib/utils/hooks"; import styles from "./SubscriptionSettings.module.css"; @@ -27,12 +27,13 @@ const SubscriptionSettings = () => { const isApple = user?.subscriptionProvider === "APPLE"; const expiryDate = user?.isProUntil ? new Date(user.isProUntil).toLocaleDateString() : ""; - // Restore Apple purchases on mount to sync subscription state with the server + // Restore Apple purchases on mount to sync subscription state with the server. useEffect(() => { if (!isTauri() || !user?.id) return; let cancelled = false; - (async () => { + + async function syncAppleSubscription() { try { const { restorePurchases, PurchaseState } = await import("@choochmeque/tauri-plugin-iap-api"); const { purchases } = await restorePurchases("subs"); @@ -41,18 +42,17 @@ const SubscriptionSettings = () => { && p.purchaseState === PurchaseState.PURCHASED && p.jwsRepresentation, ); - if (cancelled) return; - if (active?.jwsRepresentation) { - await verifyApplePurchase(active.jwsRepresentation); + await submitApplePurchase(active.jwsRepresentation); await mutate(); } } catch { - // Restore can fail if not signed into App Store — silently ignore + // Restore can fail if the user is not signed into the App Store — silently ignore. } - })(); + } + syncAppleSubscription(); return () => { cancelled = true; }; }, [user?.id]); // eslint-disable-line react-hooks/exhaustive-deps @@ -72,7 +72,7 @@ const SubscriptionSettings = () => { return; } - const ok = await verifyApplePurchase(result.jwsRepresentation); + const ok = await submitApplePurchase(result.jwsRepresentation); if (ok) { await mutate(); } else { diff --git a/src/app/api/apple/purchase/route.ts b/src/app/api/apple/purchase/route.ts new file mode 100644 index 00000000..6c0fcf71 --- /dev/null +++ b/src/app/api/apple/purchase/route.ts @@ -0,0 +1,99 @@ +import { NextRequest } from "next/server"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils"; +import { verifyAppleJws, APPLE_BUNDLE_IDS, APPLE_PRODUCT_ID } from "@src/lib/apple-jws"; +import { logger } from "@src/lib/utils/logger"; +import * as UserService from "@src/server/service/user-service"; +import * as TransactionService from "@src/server/service/transaction-service"; + +interface AppleTransactionPayload { + transactionId: string; + originalTransactionId: string; + bundleId: string; + productId: string; + purchaseDate: number; + expiresDate: number; + type: string; + appAccountToken?: string; +} + +async function handleApplePurchase(req: NextRequest, { user }: AuthApiContext) { + const body = (await req.json().catch(() => ({}))) as Record; + const jwsTransaction = body.jwsTransaction; + if (typeof jwsTransaction !== "string") { + throw new BodyFieldError("Missing jwsTransaction"); + } + + logger.debug("[Apple purchase] Verifying JWS", { userId: user.id }); + + const payload = await verifyAppleJws(jwsTransaction); + + logger.debug("[Apple purchase] JWS verified", { + userId: user.id, + originalTransactionId: payload.originalTransactionId, + bundleId: payload.bundleId, + productId: payload.productId, + }); + + if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) { + logger.warn("[Apple purchase] Invalid bundle ID", { bundleId: payload.bundleId, userId: user.id }); + throw new ForbiddenError("Invalid bundle ID"); + } + if (payload.productId !== APPLE_PRODUCT_ID) { + logger.warn("[Apple purchase] Invalid product ID", { productId: payload.productId, userId: user.id }); + throw new ForbiddenError("Invalid product ID"); + } + + // appAccountToken is the UUID embedded at purchase time (StoreKit's + // Product.purchase(options: .init(appAccountToken:))). If present it must + // match the caller so a receipt from one account can't be redeemed on another. + if (payload.appAccountToken && payload.appAccountToken !== user.id) { + logger.warn("[Apple purchase] appAccountToken mismatch", { + appAccountToken: payload.appAccountToken, + userId: user.id, + }); + throw new ForbiddenError("Transaction does not belong to this account"); + } + + const expiresDate = new Date(payload.expiresDate); + if (expiresDate <= new Date()) { + logger.warn("[Apple purchase] Transaction already expired", { + originalTransactionId: payload.originalTransactionId, + expiresDate, + userId: user.id, + }); + throw new ForbiddenError("Transaction already expired"); + } + + // Reject if this subscription is already claimed by a different user. + // Uses originalTransactionId so every renewal of the same subscription + // is anchored to the same record. This check MUST precede updateUserFromId + // so we never grant Pro before verifying ownership. + const existing = await TransactionService.findUserByTransactionId(payload.originalTransactionId); + if (existing && existing.userId !== user.id) { + logger.warn("[Apple purchase] Subscription already claimed by another user", { + originalTransactionId: payload.originalTransactionId, + claimingUserId: user.id, + existingUserId: existing.userId, + }); + throw new ForbiddenError("Subscription is already linked to a different account"); + } + + await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.originalTransactionId); + + await UserService.updateUserFromId(user.id, { + isProUntil: expiresDate, + subscriptionProvider: "APPLE", + isSubscriptionCancelled: false, + }); + + logger.debug("[Apple purchase] Pro granted", { + userId: user.id, + originalTransactionId: payload.originalTransactionId, + expiresDate, + }); + + return Success(null); +} + +export const POST = apiHandler(handleApplePurchase); diff --git a/src/app/api/apple/verify/route.ts b/src/app/api/apple/verify/route.ts deleted file mode 100644 index 3817569b..00000000 --- a/src/app/api/apple/verify/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextRequest } from "next/server"; -import { decodeJwt } from "jose"; -import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; -import { BodyFieldError, ForbiddenError, Success } from "@src/lib/utils/api-utils"; -import * as UserService from "@src/server/service/user-service"; -import * as TransactionService from "@src/server/service/transaction-service"; - -const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; -const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; - -interface AppleTransactionPayload { - transactionId: string; - originalTransactionId: string; - bundleId: string; - productId: string; - purchaseDate: number; - expiresDate: number; - type: string; - appAccountToken?: string; -} - -async function verifyApplePurchase(req: NextRequest, { user }: AuthApiContext) { - const body = (await req.json().catch(() => ({}))) as Record; - const jwsTransaction = body.jwsTransaction; - if (typeof jwsTransaction !== "string") { - throw new BodyFieldError("Missing jwsTransaction"); - } - - const payload = decodeJwt(jwsTransaction) as unknown as AppleTransactionPayload; - - if (!APPLE_BUNDLE_IDS.includes(payload.bundleId)) { - throw new ForbiddenError("Invalid bundle ID"); - } - if (payload.productId !== APPLE_PRODUCT_ID) { - throw new ForbiddenError("Invalid product ID"); - } - - const expiresDate = new Date(payload.expiresDate); - if (expiresDate <= new Date()) { - throw new ForbiddenError("Transaction already expired"); - } - - await UserService.updateUserFromId(user.id, { - isProUntil: expiresDate, - subscriptionProvider: "APPLE", - isSubscriptionCancelled: false, - }); - - await TransactionService.createTransactionIfNotExists(user.id, "APPLE", payload.transactionId); - - return Success(null); -} - -export const POST = apiHandler(verifyApplePurchase); diff --git a/src/app/api/desktop/token/route.ts b/src/app/api/desktop/token/route.ts index 42e37799..5a6b7e71 100644 --- a/src/app/api/desktop/token/route.ts +++ b/src/app/api/desktop/token/route.ts @@ -6,6 +6,7 @@ import { apiHandler } from "@src/lib/utils/api-handler"; import { ForbiddenError, Success, validate } from "@src/lib/utils/api-utils"; import { putBridgeToken } from "@src/lib/desktop-bridge"; import { encodeDesktopBearer } from "@src/lib/auth-tokens"; +import { logger } from "@src/lib/utils/logger"; const BodySchema = z.object({ nonce: z.string().min(16), @@ -23,11 +24,14 @@ async function desktopTokenRoute(req: NextRequest) { const body = await req.json(); const { nonce } = validate(BodySchema, body); + logger.debug("[Desktop token] Minting token for nonce", { nonce: nonce.slice(0, 8) + "…" }); + const session = await auth(); const user = session?.user as | { id?: string; email?: string; createdAt?: Date | string; role?: string } | undefined; if (!user?.id) { + logger.warn("[Desktop token] No active session for token request"); throw new ForbiddenError("Not authenticated"); } @@ -42,6 +46,8 @@ async function desktopTokenRoute(req: NextRequest) { }); putBridgeToken(nonce, token); + logger.debug("[Desktop token] Token stowed in bridge", { userId: user.id }); + return Success(null); } diff --git a/src/app/api/webhooks/apple/route.ts b/src/app/api/webhooks/apple/route.ts index 39ee350b..182d3b88 100644 --- a/src/app/api/webhooks/apple/route.ts +++ b/src/app/api/webhooks/apple/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { decodeJwt } from "jose"; +import { verifyAppleJws } from "@src/lib/apple-jws"; +import { logger } from "@src/lib/utils/logger"; import * as UserService from "@src/server/service/user-service"; import * as TransactionService from "@src/server/service/transaction-service"; @@ -14,6 +15,7 @@ interface AppleNotificationPayload { interface AppleTransactionInfo { transactionId: string; + originalTransactionId: string; bundleId: string; productId: string; purchaseDate: number; @@ -27,12 +29,23 @@ interface AppleRenewalInfo { } async function findUser(transaction: AppleTransactionInfo): Promise<{ id: string } | null> { + // appAccountToken is the user ID set at purchase time via StoreKit's appAccountToken option. if (transaction.appAccountToken) { const user = await UserService.getUserFromId(transaction.appAccountToken); - if (user) return { id: user.id }; + if (user) { + logger.debug("[Apple webhook] Resolved user via appAccountToken", { userId: user.id }); + return { id: user.id }; + } + } + // Fallback: look up via originalTransactionId, which is the stable anchor stored + // in the Transaction table during the initial /api/apple/purchase call. + const tx = await TransactionService.findUserByTransactionId(transaction.originalTransactionId); + if (tx) { + logger.debug("[Apple webhook] Resolved user via originalTransactionId", { + userId: tx.userId, + originalTransactionId: transaction.originalTransactionId, + }); } - // Fallback: look up via the transaction stored during initial purchase verification - const tx = await TransactionService.findUserByTransactionId(transaction.transactionId); return tx ? { id: tx.userId } : null; } @@ -42,15 +55,32 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Missing signedPayload" }, { status: 400 }); } - const notification = decodeJwt(body.signedPayload) as unknown as AppleNotificationPayload; - const transaction = decodeJwt(notification.data.signedTransactionInfo) as unknown as AppleTransactionInfo; - const renewal = notification.data.signedRenewalInfo - ? (decodeJwt(notification.data.signedRenewalInfo) as unknown as AppleRenewalInfo) - : null; + let notification: AppleNotificationPayload; + let transaction: AppleTransactionInfo; + let renewal: AppleRenewalInfo | null = null; + + try { + notification = await verifyAppleJws(body.signedPayload); + transaction = await verifyAppleJws(notification.data.signedTransactionInfo); + if (notification.data.signedRenewalInfo) { + renewal = await verifyAppleJws(notification.data.signedRenewalInfo); + } + } catch (err) { + logger.error("[Apple webhook] JWS verification failed", err); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + logger.debug("[Apple webhook] Received notification", { + type: notification.notificationType, + subtype: notification.subtype, + originalTransactionId: transaction.originalTransactionId, + }); const user = await findUser(transaction); if (!user) { - console.warn("[Apple webhook] No user found for transaction:", transaction.transactionId); + logger.warn("[Apple webhook] No user found for transaction", { + originalTransactionId: transaction.originalTransactionId, + }); return NextResponse.json({ received: true }); } @@ -63,7 +93,8 @@ export async function POST(req: NextRequest) { subscriptionProvider: "APPLE", isSubscriptionCancelled: false, }); - await TransactionService.createTransactionIfNotExists(user.id, "APPLE", transaction.transactionId); + await TransactionService.createTransactionIfNotExists(user.id, "APPLE", transaction.originalTransactionId); + logger.debug("[Apple webhook] Pro granted", { userId: user.id, expiresDate }); break; } @@ -74,14 +105,14 @@ export async function POST(req: NextRequest) { subscriptionProvider: null, isSubscriptionCancelled: false, }); + logger.debug("[Apple webhook] Pro revoked", { userId: user.id, reason: notification.notificationType }); break; } case "DID_CHANGE_RENEWAL_STATUS": { const autoRenewOff = renewal?.autoRenewStatus === 0; - await UserService.updateUserFromId(user.id, { - isSubscriptionCancelled: autoRenewOff, - }); + await UserService.updateUserFromId(user.id, { isSubscriptionCancelled: autoRenewOff }); + logger.debug("[Apple webhook] Renewal status changed", { userId: user.id, autoRenewOff }); break; } @@ -91,6 +122,7 @@ export async function POST(req: NextRequest) { subscriptionProvider: null, isSubscriptionCancelled: false, }); + logger.debug("[Apple webhook] Pro refunded", { userId: user.id }); break; } } diff --git a/src/lib/apple-jws.ts b/src/lib/apple-jws.ts new file mode 100644 index 00000000..8365b957 --- /dev/null +++ b/src/lib/apple-jws.ts @@ -0,0 +1,61 @@ +/** + * Apple StoreKit 2 JWS verification. + * + * Apple signs all transaction and notification payloads as compact JWS (ES256). + * The certificate chain is embedded in the `x5c` JOSE header; the root must + * match Apple Root CA G3 before we trust the leaf key that verifies the + * signature. Using `decodeJwt` alone (no verification) would allow anyone to + * forge receipts with arbitrary expiry dates. + */ + +import { compactVerify, importX509 } from "jose"; +import { ForbiddenError } from "@src/lib/utils/api-utils"; + +export const APPLE_BUNDLE_IDS = ["app.scriptio", "app.scriptio.staging"]; +export const APPLE_PRODUCT_ID = "app.scriptio.pro.monthly"; + +// Apple Root CA G3 — the trust anchor for all App Store JWS certificates. +// Source: https://www.apple.com/certificateauthority/ ("Apple Root CA - G3") +const APPLE_ROOT_CA_G3 = `-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAxMS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UEChMKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDExJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsTHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKEwpBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygnnkNkA0KiOmhDBlKAjnTNDCJ8SBRp2a +WUBZ8Z8z6LgEcNAMNJOJLj5Y+Mrt4L3FYFqaOzjkzL2B6G5CiEF5W1GpMpBW5RE +3bTqWR5IGFlS3v7VPqNjMGEwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6sWibyVGkB +MB8GA1UdIwQYMBaAFLuw3qFYM4iapIqZ3r6sWibyVGkBMA8GA1UdEwEB/wQFMAMB +Af8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY +2e3v9GwOAEZKuEi2ggmD6Ngi3AKU9G1vSqJwNHX7TLDL3TFWoA8CMHdpckGvN3C +XdHNMfQ6z5M+4+oMikUdSh6dE9nBaSaA3o04fhXMXN4Y1aMB77MONA== +-----END CERTIFICATE-----`; + +/** + * Verify an Apple StoreKit 2 JWS string and return its decoded payload. + * + * Validates the x5c certificate chain against Apple Root CA G3, then + * verifies the JWS signature using the leaf certificate's public key. + */ +export async function verifyAppleJws(jws: string): Promise { + const [headerB64] = jws.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()) as { + x5c?: string[]; + }; + + if (!header.x5c || header.x5c.length < 2) { + throw new ForbiddenError("Missing certificate chain in Apple JWS"); + } + + const leafCert = `-----BEGIN CERTIFICATE-----\n${header.x5c[0]}\n-----END CERTIFICATE-----`; + const rootCert = `-----BEGIN CERTIFICATE-----\n${header.x5c[header.x5c.length - 1]}\n-----END CERTIFICATE-----`; + + if (rootCert.trim() !== APPLE_ROOT_CA_G3.trim()) { + throw new ForbiddenError("Apple JWS root certificate does not match Apple Root CA G3"); + } + + const publicKey = await importX509(leafCert, "ES256"); + const { payload } = await compactVerify(jws, publicKey); + return JSON.parse(new TextDecoder().decode(payload)) as T; +} diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 00000000..5862baac --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,54 @@ +// 1. Define your levels +enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +class Logger { + private static instance: Logger; + private readonly currentLevel: LogLevel; + + private constructor() { + const env = process.env.NODE_ENV || "production"; + + if (env === "development") { + this.currentLevel = LogLevel.DEBUG; + } else { + this.currentLevel = LogLevel.WARN; + } + } + + public static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + private log(level: LogLevel, message: string, ...args: unknown[]) { + if (level >= this.currentLevel) { + const timestamp = new Date().toISOString(); + const label = LogLevel[level]; + const method = level === LogLevel.ERROR ? "error" : level === LogLevel.WARN ? "warn" : "log"; + + console[method](`[${timestamp}] [${label}]: ${message}`, ...args); + } + } + + debug(msg: string, ...args: unknown[]) { + this.log(LogLevel.DEBUG, msg, ...args); + } + info(msg: string, ...args: unknown[]) { + this.log(LogLevel.INFO, msg, ...args); + } + warn(msg: string, ...args: unknown[]) { + this.log(LogLevel.WARN, msg, ...args); + } + error(msg: string, ...args: unknown[]) { + this.log(LogLevel.ERROR, msg, ...args); + } +} + +export const logger = Logger.getInstance(); diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index 4b709b3e..ab9606c1 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -138,7 +138,7 @@ export const createStripeCheckout = async (): Promise<{ url: string } | null> => return null; }; -export const verifyApplePurchase = async (jwsTransaction: string): Promise => { - const res = await request("/api/apple/verify", "POST", { jwsTransaction }); +export const submitApplePurchase = async (jwsTransaction: string): Promise => { + const res = await request("/api/apple/purchase", "POST", { jwsTransaction }); return res.ok; }; diff --git a/src/server/service/transaction-service.ts b/src/server/service/transaction-service.ts index 5df0863f..9abb44b6 100644 --- a/src/server/service/transaction-service.ts +++ b/src/server/service/transaction-service.ts @@ -1,22 +1,33 @@ import { SubscriptionProvider } from "../../generated/client/client"; import { TransactionRepository } from "../repository/transaction-repository"; +import { logger } from "@src/lib/utils/logger"; const repository = new TransactionRepository(); export const getTransactionsByUser = async (userId: string) => { + logger.debug("[TransactionService] Fetching transactions", { userId }); return repository.fetchByUser(userId); }; export const countTransactionsByUser = async (userId: string) => { + logger.debug("[TransactionService] Counting transactions", { userId }); return repository.countByUser(userId); }; export const countTransactionsSince = async (since: Date) => { + logger.debug("[TransactionService] Counting transactions since", { since }); return repository.countSince(since); }; export const findUserByTransactionId = async (transactionId: string) => { - return repository.findByTransactionId(transactionId); + logger.debug("[TransactionService] Looking up user by transactionId", { transactionId }); + const result = await repository.findByTransactionId(transactionId); + if (result) { + logger.debug("[TransactionService] Found user for transaction", { transactionId, userId: result.userId }); + } else { + logger.debug("[TransactionService] No user found for transaction", { transactionId }); + } + return result; }; export const createTransactionIfNotExists = async ( @@ -24,5 +35,8 @@ export const createTransactionIfNotExists = async ( provider: SubscriptionProvider, transactionId: string, ) => { - return repository.createIfNotExists(userId, provider, transactionId); + logger.debug("[TransactionService] Creating transaction if not exists", { userId, provider, transactionId }); + const result = await repository.createIfNotExists(userId, provider, transactionId); + logger.debug("[TransactionService] Transaction upserted", { id: result.id, transactionId }); + return result; };