diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index e4681362..42365f34 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -844,6 +844,15 @@ public struct VerifyPurchaseResultAndroid: Codable { public var testTransaction: Bool } +/// Result from Meta Horizon verify_entitlement API. +/// Returns verification status and grant time for the entitlement. +public struct VerifyPurchaseResultHorizon: Codable { + /// Unix timestamp (seconds) when the entitlement was granted. + public var grantTime: Double? + /// Whether the entitlement verification succeeded. + public var success: Bool +} + public struct VerifyPurchaseResultIOS: Codable { /// Whether the receipt is valid public var isValid: Bool @@ -1142,6 +1151,12 @@ public struct RequestPurchaseProps: Codable { } } +/// Platform-specific purchase request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) public struct RequestPurchasePropsByPlatforms: Codable { /// @deprecated Use google instead public var android: RequestPurchaseAndroidProps? @@ -1228,6 +1243,12 @@ public struct RequestSubscriptionIosProps: Codable { } } +/// Platform-specific subscription request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) public struct RequestSubscriptionPropsByPlatforms: Codable { /// @deprecated Use google instead public var android: RequestSubscriptionAndroidProps? @@ -1273,12 +1294,16 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } } +/// Platform-specific verification parameters for IAPKit. +/// +/// - apple: Verifies via App Store (JWS token) +/// - google: Verifies via Play Store (purchase token) public struct RequestVerifyPurchaseWithIapkitProps: Codable { /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? - /// Apple verification parameters. + /// Apple App Store verification parameters. public var apple: RequestVerifyPurchaseWithIapkitAppleProps? - /// Google verification parameters. + /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? public init( @@ -1310,36 +1335,100 @@ public struct SubscriptionProductReplacementParamsAndroid: Codable { } } -public struct VerifyPurchaseAndroidOptions: Codable { +/// Apple App Store verification parameters. +/// Used for server-side receipt validation via App Store Server API. +/// +/// ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. +public struct VerifyPurchaseAppleOptions: Codable { + /// The JWS (JSON Web Signature) representation of the transaction. + /// ⚠️ Sensitive: Do not log this value. + public var jws: String + + public init( + jws: String + ) { + self.jws = jws + } +} + +/// Google Play Store verification parameters. +/// Used for server-side receipt validation via Google Play Developer API. +/// +/// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +public struct VerifyPurchaseGoogleOptions: Codable { + /// Google OAuth2 access token for API authentication. + /// ⚠️ Sensitive: Do not log this value. public var accessToken: String + /// Whether this is a subscription purchase (affects API endpoint used) public var isSub: Bool? + /// Android package name (e.g., com.example.app) public var packageName: String - public var productToken: String + /// Purchase token from the purchase response. + /// ⚠️ Sensitive: Do not log this value. + public var purchaseToken: String public init( accessToken: String, isSub: Bool? = nil, packageName: String, - productToken: String + purchaseToken: String ) { self.accessToken = accessToken self.isSub = isSub self.packageName = packageName - self.productToken = productToken + self.purchaseToken = purchaseToken } } +/// Meta Horizon (Quest) verification parameters. +/// Used for server-side entitlement verification via Meta's S2S API. +/// POST https://graph.oculus.com/$APP_ID/verify_entitlement +/// +/// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +public struct VerifyPurchaseHorizonOptions: Codable { + /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + /// ⚠️ Sensitive: Do not log this value. + public var accessToken: String + /// The SKU for the add-on item, defined in Meta Developer Dashboard + public var sku: String + /// The user ID of the user whose purchase you want to verify + public var userId: String + + public init( + accessToken: String, + sku: String, + userId: String + ) { + self.accessToken = accessToken + self.sku = sku + self.userId = userId + } +} + +/// Platform-specific purchase verification parameters. +/// +/// - apple: Verifies via App Store Server API +/// - google: Verifies via Google Play Developer API +/// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) public struct VerifyPurchaseProps: Codable { - /// Android-specific validation options - public var androidOptions: VerifyPurchaseAndroidOptions? + /// Apple App Store verification parameters. + public var apple: VerifyPurchaseAppleOptions? + /// Google Play Store verification parameters. + public var google: VerifyPurchaseGoogleOptions? + /// Meta Horizon (Quest) verification parameters. + public var horizon: VerifyPurchaseHorizonOptions? /// Product SKU to validate public var sku: String public init( - androidOptions: VerifyPurchaseAndroidOptions? = nil, + apple: VerifyPurchaseAppleOptions? = nil, + google: VerifyPurchaseGoogleOptions? = nil, + horizon: VerifyPurchaseHorizonOptions? = nil, sku: String ) { - self.androidOptions = androidOptions + self.apple = apple + self.google = google + self.horizon = horizon self.sku = sku } } @@ -1667,6 +1756,7 @@ public enum Purchase: Codable, PurchaseCommon { public enum VerifyPurchaseResult: Codable { case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid) case verifyPurchaseResultIos(VerifyPurchaseResultIOS) + case verifyPurchaseResultHorizon(VerifyPurchaseResultHorizon) } // MARK: - Root Operations diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 8c30fff6..281696e5 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -598,16 +598,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { var jws: String = "" var isValid = false - do { - let product = try await storeProduct(for: props.sku) - if let result = await product.latestTransaction { - jws = result.jwsRepresentation - let transaction = try checkVerified(result) - latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation)) - isValid = true + // If apple options with JWS are provided, use that directly + // Otherwise, fetch the latest transaction from StoreKit + if let appleOptions = props.apple, !appleOptions.jws.isEmpty { + jws = appleOptions.jws + // When JWS is provided externally, we trust it's valid + // The caller should verify the JWS on their server + isValid = true + } else { + do { + let product = try await storeProduct(for: props.sku) + if let result = await product.latestTransaction { + jws = result.jwsRepresentation + let transaction = try checkVerified(result) + latestPurchase = .purchaseIos(await StoreKitTypesBridge.purchaseIOS(from: transaction, jwsRepresentation: result.jwsRepresentation)) + isValid = true + } + } catch { + isValid = false } - } catch { - isValid = false } return VerifyPurchaseResultIOS( diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx index 810c8ae5..fa7c0f9e 100644 --- a/packages/docs/src/pages/docs/updates/notes.tsx +++ b/packages/docs/src/pages/docs/updates/notes.tsx @@ -19,6 +19,91 @@ function Notes() {

📝 API & Terminology Changes

+
+

+ 📅 openiap-gql v1.3.3 / openiap-google v1.3.13 / openiap-apple v1.3.1 + - Platform-Specific Verification Options +

+

+ verifyPurchase API Refactored: +

+

+ The verifyPurchase API now supports platform-specific + options for Apple, Google, and Meta Horizon stores. +

+ +

+ New VerifyPurchaseProps Structure: +

+ + {`// Platform-specific verification (recommended) +verifyPurchase({ + sku: 'premium_monthly', + apple: { jws: 'eyJ...' }, // iOS App Store + google: { // Google Play + packageName: 'com.example.app', + purchaseToken: 'token...', + accessToken: 'oauth_token...', + isSub: true + }, + horizon: { // Meta Quest + sku: '50_gems', + userId: '123456789', + accessToken: 'OC|app_id|app_secret' + } +}) + +// Legacy format still supported (deprecated) +verifyPurchase({ + sku: 'premium_monthly', + androidOptions: { ... } // @deprecated - use google instead +})`} + +

+ Deprecations: +

+ +

+ See:{' '} + verifyPurchase API,{' '} + VerifyPurchaseProps +

+
+
- verifyPurchaseWithGooglePlay(props, TAG) + // Use Horizon API if horizon options provided, otherwise fallback to Google Play + if (props.horizon != null) { + val horizonAppId = appId ?: throw OpenIapError.DeveloperError + val horizonResult = verifyPurchaseWithHorizon(props, horizonAppId, TAG) + if (!horizonResult.success) { + throw OpenIapError.InvalidPurchaseVerification + } + horizonResult + } else { + verifyPurchaseWithGooglePlay(props, TAG) + } } override val verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler = { props -> diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index f193f38e..b4ae4233 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -2336,6 +2336,37 @@ public data class VerifyPurchaseResultAndroid( ) } +/** + * Result from Meta Horizon verify_entitlement API. + * Returns verification status and grant time for the entitlement. + */ +public data class VerifyPurchaseResultHorizon( + /** + * Unix timestamp (seconds) when the entitlement was granted. + */ + val grantTime: Double? = null, + /** + * Whether the entitlement verification succeeded. + */ + val success: Boolean +) : VerifyPurchaseResult { + + companion object { + fun fromJson(json: Map): VerifyPurchaseResultHorizon { + return VerifyPurchaseResultHorizon( + grantTime = (json["grantTime"] as Number?)?.toDouble(), + success = json["success"] as Boolean, + ) + } + } + + override fun toJson(): Map = mapOf( + "__typename" to "VerifyPurchaseResultHorizon", + "grantTime" to grantTime, + "success" to success, + ) +} + public data class VerifyPurchaseResultIOS( /** * Whether the receipt is valid @@ -2767,6 +2798,14 @@ public data class RequestPurchaseProps( } } +/** + * Platform-specific purchase request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ public data class RequestPurchasePropsByPlatforms( /** * @deprecated Use google instead @@ -2895,6 +2934,14 @@ public data class RequestSubscriptionIosProps( ) } +/** + * Platform-specific subscription request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ public data class RequestSubscriptionPropsByPlatforms( /** * @deprecated Use google instead @@ -2970,17 +3017,23 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( ) } +/** + * Platform-specific verification parameters for IAPKit. + * + * - apple: Verifies via App Store (JWS token) + * - google: Verifies via Play Store (purchase token) + */ public data class RequestVerifyPurchaseWithIapkitProps( /** * API key used for the Authorization header (Bearer {apiKey}). */ val apiKey: String? = null, /** - * Apple verification parameters. + * Apple App Store verification parameters. */ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, /** - * Google verification parameters. + * Google Play Store verification parameters. */ val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) { @@ -3031,19 +3084,65 @@ public data class SubscriptionProductReplacementParamsAndroid( ) } -public data class VerifyPurchaseAndroidOptions( +/** + * Apple App Store verification parameters. + * Used for server-side receipt validation via App Store Server API. + * + * ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. + */ +public data class VerifyPurchaseAppleOptions( + /** + * The JWS (JSON Web Signature) representation of the transaction. + * ⚠️ Sensitive: Do not log this value. + */ + val jws: String +) { + companion object { + fun fromJson(json: Map): VerifyPurchaseAppleOptions { + return VerifyPurchaseAppleOptions( + jws = json["jws"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "jws" to jws, + ) +} + +/** + * Google Play Store verification parameters. + * Used for server-side receipt validation via Google Play Developer API. + * + * ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. + */ +public data class VerifyPurchaseGoogleOptions( + /** + * Google OAuth2 access token for API authentication. + * ⚠️ Sensitive: Do not log this value. + */ val accessToken: String, + /** + * Whether this is a subscription purchase (affects API endpoint used) + */ val isSub: Boolean? = null, + /** + * Android package name (e.g., com.example.app) + */ val packageName: String, - val productToken: String + /** + * Purchase token from the purchase response. + * ⚠️ Sensitive: Do not log this value. + */ + val purchaseToken: String ) { companion object { - fun fromJson(json: Map): VerifyPurchaseAndroidOptions { - return VerifyPurchaseAndroidOptions( + fun fromJson(json: Map): VerifyPurchaseGoogleOptions { + return VerifyPurchaseGoogleOptions( accessToken = json["accessToken"] as String, isSub = json["isSub"] as Boolean?, packageName = json["packageName"] as String, - productToken = json["productToken"] as String, + purchaseToken = json["purchaseToken"] as String, ) } } @@ -3052,15 +3151,69 @@ public data class VerifyPurchaseAndroidOptions( "accessToken" to accessToken, "isSub" to isSub, "packageName" to packageName, - "productToken" to productToken, + "purchaseToken" to purchaseToken, + ) +} + +/** + * Meta Horizon (Quest) verification parameters. + * Used for server-side entitlement verification via Meta's S2S API. + * POST https://graph.oculus.com/$APP_ID/verify_entitlement + * + * ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. + */ +public data class VerifyPurchaseHorizonOptions( + /** + * Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + * ⚠️ Sensitive: Do not log this value. + */ + val accessToken: String, + /** + * The SKU for the add-on item, defined in Meta Developer Dashboard + */ + val sku: String, + /** + * The user ID of the user whose purchase you want to verify + */ + val userId: String +) { + companion object { + fun fromJson(json: Map): VerifyPurchaseHorizonOptions { + return VerifyPurchaseHorizonOptions( + accessToken = json["accessToken"] as String, + sku = json["sku"] as String, + userId = json["userId"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "accessToken" to accessToken, + "sku" to sku, + "userId" to userId, ) } +/** + * Platform-specific purchase verification parameters. + * + * - apple: Verifies via App Store Server API + * - google: Verifies via Google Play Developer API + * - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) + */ public data class VerifyPurchaseProps( /** - * Android-specific validation options + * Apple App Store verification parameters. */ - val androidOptions: VerifyPurchaseAndroidOptions? = null, + val apple: VerifyPurchaseAppleOptions? = null, + /** + * Google Play Store verification parameters. + */ + val google: VerifyPurchaseGoogleOptions? = null, + /** + * Meta Horizon (Quest) verification parameters. + */ + val horizon: VerifyPurchaseHorizonOptions? = null, /** * Product SKU to validate */ @@ -3069,14 +3222,18 @@ public data class VerifyPurchaseProps( companion object { fun fromJson(json: Map): VerifyPurchaseProps { return VerifyPurchaseProps( - androidOptions = (json["androidOptions"] as Map?)?.let { VerifyPurchaseAndroidOptions.fromJson(it) }, + apple = (json["apple"] as Map?)?.let { VerifyPurchaseAppleOptions.fromJson(it) }, + google = (json["google"] as Map?)?.let { VerifyPurchaseGoogleOptions.fromJson(it) }, + horizon = (json["horizon"] as Map?)?.let { VerifyPurchaseHorizonOptions.fromJson(it) }, sku = json["sku"] as String, ) } } fun toJson(): Map = mapOf( - "androidOptions" to androidOptions?.toJson(), + "apple" to apple?.toJson(), + "google" to google?.toJson(), + "horizon" to horizon?.toJson(), "sku" to sku, ) } @@ -3175,6 +3332,7 @@ public sealed interface VerifyPurchaseResult { fun fromJson(json: Map): VerifyPurchaseResult { return when (json["__typename"] as String?) { "VerifyPurchaseResultAndroid" -> VerifyPurchaseResultAndroid.fromJson(json) + "VerifyPurchaseResultHorizon" -> VerifyPurchaseResultHorizon.fromJson(json) "VerifyPurchaseResultIOS" -> VerifyPurchaseResultIOS.fromJson(json) else -> throw IllegalArgumentException("Unknown __typename for VerifyPurchaseResult: ${json["__typename"]}") } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt index b275c262..b3e97875 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/utils/PurchaseVerificationValidator.kt @@ -10,11 +10,13 @@ import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitResult import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.VerifyPurchaseResultAndroid +import dev.hyo.openiap.VerifyPurchaseResultHorizon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException import java.net.HttpURLConnection import java.net.URL +import java.net.URLEncoder private const val DEFAULT_IAPKIT_ENDPOINT = "https://api.iapkit.com/v1/purchase/verify" private val gson = Gson() @@ -23,34 +25,39 @@ private fun openConnection(url: String): HttpURLConnection { return URL(url).openConnection() as HttpURLConnection } +private fun encodePathSegment(value: String): String = + URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") + suspend fun verifyPurchaseWithGooglePlay( props: VerifyPurchaseProps, tag: String, connectionFactory: (String) -> HttpURLConnection = ::openConnection ): VerifyPurchaseResultAndroid = withContext(Dispatchers.IO) { - val options = props.androidOptions + val googleOptions = props.google ?: throw IllegalArgumentException( - "Android validation requires packageName, productToken, and accessToken" + "Google Play validation requires google options (packageName, purchaseToken, accessToken)" ) - if ( - options.packageName.isBlank() || - options.productToken.isBlank() || - options.accessToken.isBlank() - ) { + val packageName = googleOptions.packageName + val purchaseToken = googleOptions.purchaseToken + val accessToken = googleOptions.accessToken + val isSub = googleOptions.isSub + + if (packageName.isBlank() || purchaseToken.isBlank() || accessToken.isBlank()) { throw IllegalArgumentException( - "Android validation requires packageName, productToken, and accessToken" + "Google Play validation requires packageName, purchaseToken, and accessToken" ) } - val typeSegment = if (options.isSub == true) "subscriptions" else "products" + val typeSegment = if (isSub == true) "subscriptions" else "products" val baseUrl = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" - val url = "$baseUrl/${options.packageName}/purchases/$typeSegment/${props.sku}/tokens/${options.productToken}" + val url = "$baseUrl/${encodePathSegment(packageName)}/purchases/$typeSegment/" + + "${encodePathSegment(props.sku)}/tokens/${encodePathSegment(purchaseToken)}" val connection = connectionFactory(url).apply { requestMethod = "GET" setRequestProperty("Content-Type", "application/json") - setRequestProperty("Authorization", "Bearer ${options.accessToken}") + setRequestProperty("Authorization", "Bearer $accessToken") } try { @@ -80,6 +87,81 @@ suspend fun verifyPurchaseWithGooglePlay( } } +/** + * Verify purchase with Meta Horizon S2S API. + * POST https://graph.oculus.com/$APP_ID/verify_entitlement + */ +suspend fun verifyPurchaseWithHorizon( + props: VerifyPurchaseProps, + appId: String, + tag: String, + connectionFactory: (String) -> HttpURLConnection = ::openConnection +): VerifyPurchaseResultHorizon = withContext(Dispatchers.IO) { + val horizonOptions = props.horizon + ?: throw IllegalArgumentException( + "Horizon validation requires horizon options (sku, userId, accessToken)" + ) + + if (horizonOptions.sku.isBlank() || horizonOptions.userId.isBlank() || horizonOptions.accessToken.isBlank()) { + throw IllegalArgumentException( + "Horizon validation requires sku, userId, and accessToken" + ) + } + + val url = "https://graph.oculus.com/$appId/verify_entitlement" + + val connection = connectionFactory(url).apply { + requestMethod = "POST" + doOutput = true + setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + } + + try { + // Build form data + val formData = buildString { + append("access_token=${java.net.URLEncoder.encode(horizonOptions.accessToken, "UTF-8")}") + append("&user_id=${java.net.URLEncoder.encode(horizonOptions.userId, "UTF-8")}") + append("&sku=${java.net.URLEncoder.encode(horizonOptions.sku, "UTF-8")}") + } + + connection.outputStream.use { stream -> + stream.write(formData.toByteArray(Charsets.UTF_8)) + } + + val statusCode = connection.responseCode + val responseBody = (if (statusCode in 200..299) connection.inputStream else connection.errorStream) + ?.bufferedReader() + ?.use { it.readText() } + .orElse("") + + if (statusCode !in 200..299) { + OpenIapLog.warn("Horizon verifyPurchase failed (HTTP $statusCode)", tag) + throw OpenIapError.InvalidPurchaseVerification + } + + try { + // Response: {"success":true,"grant_time":1744148687} + val mapType = object : TypeToken>() {}.type + val parsed = gson.fromJson>(responseBody, mapType) + val success = parsed["success"] as? Boolean ?: false + val grantTime = (parsed["grant_time"] as? Number)?.toDouble() + + VerifyPurchaseResultHorizon( + grantTime = grantTime, + success = success + ) + } catch (jsonError: JsonSyntaxException) { + OpenIapLog.warn("Failed to parse Horizon verification response: ${jsonError.message}", tag) + throw OpenIapError.InvalidPurchaseVerification + } + } catch (io: IOException) { + OpenIapLog.warn("Network error during Horizon verification: ${io.message}", tag) + throw OpenIapError.NetworkError + } finally { + connection.disconnect() + } +} + suspend fun verifyPurchaseWithIapkit( props: RequestVerifyPurchaseWithIapkitProps, tag: String, @@ -87,13 +169,14 @@ suspend fun verifyPurchaseWithIapkit( ): RequestVerifyPurchaseWithIapkitResult = withContext(Dispatchers.IO) { val endpoint = DEFAULT_IAPKIT_ENDPOINT - // On Android, only Google verification is supported + // On Android, only Google verification is supported via IAPKit + // Note: Horizon verification requires direct S2S API calls to Meta (not yet supported) if (props.google == null) { throw IllegalArgumentException("IAPKit verification on Android requires google payload") } val store = IapStore.Google - val payload = buildPayload(props, store) + val payload = buildGooglePayload(props) val connection = connectionFactory(endpoint).apply { requestMethod = "POST" @@ -107,12 +190,8 @@ suspend fun verifyPurchaseWithIapkit( try { val body = gson.toJson(payload) - // Log request details for debugging - OpenIapLog.debug("IAPKit request URL: $endpoint", tag) - OpenIapLog.debug("IAPKit request body: $body", tag) - connection.outputStream.use { stream -> - stream.write(body.toByteArray()) + stream.write(body.toByteArray(Charsets.UTF_8)) } val statusCode = connection.responseCode @@ -121,10 +200,8 @@ suspend fun verifyPurchaseWithIapkit( ?.use { it.readText() } .orElse("") - OpenIapLog.debug("IAPKit response (HTTP $statusCode): $responseBody", tag) - if (statusCode !in 200..299) { - OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP $statusCode) [$store]: $responseBody", tag) + OpenIapLog.warn("verifyPurchaseWithProvider failed (HTTP $statusCode) [$store]", tag) // Extract concise error message from IAPKit response // IAPKit returns nested error format - extract the deepest originalError val errorMessage = try { @@ -152,7 +229,6 @@ suspend fun verifyPurchaseWithIapkit( this["store"] = store.toJson() } } - OpenIapLog.debug("IAPKit normalized response: $normalizedParsed", tag) RequestVerifyPurchaseWithIapkitResult.fromJson(normalizedParsed) } catch (jsonError: Exception) { OpenIapLog.warn("Failed to parse IAPKit verification response: ${jsonError.message}", tag) @@ -166,28 +242,22 @@ suspend fun verifyPurchaseWithIapkit( } } -private fun buildPayload( - props: RequestVerifyPurchaseWithIapkitProps, - store: IapStore -): Map { - return when (store) { - IapStore.Google, IapStore.Horizon -> { - val google = props.google - ?: throw IllegalArgumentException("IAPKit Google verification requires google options") - if (google.purchaseToken.isBlank()) { - throw IllegalArgumentException( - "IAPKit Google verification requires purchaseToken" - ) - } - mutableMapOf( - "store" to store.rawValue, - "purchaseToken" to google.purchaseToken - ) - } - else -> throw IllegalArgumentException("IAPKit verification on Android supports Google payloads only") +/** + * Build payload for Google Play Store verification via IAPKit. + */ +private fun buildGooglePayload(props: RequestVerifyPurchaseWithIapkitProps): Map { + val google = props.google + ?: throw IllegalArgumentException("IAPKit Google verification requires google options") + if (google.purchaseToken.isBlank()) { + throw IllegalArgumentException("IAPKit Google verification requires purchaseToken") } + return mutableMapOf( + "store" to IapStore.Google.rawValue, + "purchaseToken" to google.purchaseToken + ) } + private fun String?.orElse(fallback: String): String = this ?: fallback /** diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt index 9d53b45e..940265d4 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/PurchaseVerificationValidatorTest.kt @@ -2,12 +2,14 @@ package dev.hyo.openiap import com.google.gson.Gson import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay +import dev.hyo.openiap.utils.verifyPurchaseWithHorizon import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import dev.hyo.openiap.IapStore import dev.hyo.openiap.IapkitPurchaseState import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps -import dev.hyo.openiap.VerifyPurchaseAndroidOptions +import dev.hyo.openiap.VerifyPurchaseGoogleOptions +import dev.hyo.openiap.VerifyPurchaseHorizonOptions import dev.hyo.openiap.VerifyPurchaseProps import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -23,28 +25,70 @@ import org.junit.Test class PurchaseVerificationValidatorTest { @Test - fun `verifyPurchaseWithGooglePlay throws without androidOptions`() = runTest { - val props = VerifyPurchaseProps(androidOptions = null, sku = "product.sku") + fun `verifyPurchaseWithGooglePlay throws without google options`() = runTest { + val props = VerifyPurchaseProps(sku = "product.sku") try { verifyPurchaseWithGooglePlay(props, "TEST_TAG") { _ -> throw AssertionError("Connection should not be created when options are missing") } - throw AssertionError("Expected IllegalArgumentException for missing androidOptions") + throw AssertionError("Expected IllegalArgumentException for missing google options") } catch (expected: IllegalArgumentException) { // Expected path } } + @Test + fun `verifyPurchaseWithGooglePlay works with new google field`() = runTest { + val googleOptions = VerifyPurchaseGoogleOptions( + accessToken = "token", + isSub = true, + packageName = "dev.hyo.app", + purchaseToken = "purchaseToken" + ) + val props = VerifyPurchaseProps(google = googleOptions, sku = "premium_monthly") + val body = """ + { + "autoRenewing": true, + "betaProduct": false, + "cancelDate": null, + "cancelReason": null, + "deferredDate": null, + "deferredSku": null, + "freeTrialEndDate": 1.0, + "gracePeriodEndDate": 2.0, + "parentProductId": "parent", + "productId": "premium_monthly", + "productType": "subs", + "purchaseDate": 3.0, + "quantity": 1, + "receiptId": "rid-123", + "renewalDate": 4.0, + "term": "P1M", + "termSku": "plan_monthly", + "testTransaction": false + } + """.trimIndent() + + val result = verifyPurchaseWithGooglePlay( + props, + "TEST_TAG" + ) { _ -> FakeHttpURLConnection(200, body) } + + assertEquals("premium_monthly", result.productId) + assertEquals("plan_monthly", result.termSku) + assertEquals(true, result.autoRenewing) + } + @Test fun `verifyPurchaseWithGooglePlay parses successful response`() = runTest { - val options = VerifyPurchaseAndroidOptions( + val googleOptions = VerifyPurchaseGoogleOptions( accessToken = "token", isSub = true, packageName = "dev.hyo.app", - productToken = "purchaseToken" + purchaseToken = "purchaseToken" ) - val props = VerifyPurchaseProps(androidOptions = options, sku = "premium_monthly") + val props = VerifyPurchaseProps(google = googleOptions, sku = "premium_monthly") val body = """ { "autoRenewing": true, @@ -82,13 +126,13 @@ class PurchaseVerificationValidatorTest { @Test fun `verifyPurchaseWithGooglePlay wraps non-2xx as InvalidPurchaseVerification`() = runTest { - val options = VerifyPurchaseAndroidOptions( + val googleOptions = VerifyPurchaseGoogleOptions( accessToken = "token", isSub = false, packageName = "dev.hyo.app", - productToken = "purchaseToken" + purchaseToken = "purchaseToken" ) - val props = VerifyPurchaseProps(androidOptions = options, sku = "premium_monthly") + val props = VerifyPurchaseProps(google = googleOptions, sku = "premium_monthly") try { verifyPurchaseWithGooglePlay( @@ -219,6 +263,63 @@ class PurchaseVerificationValidatorTest { assertTrue(true) } } + + // ===== Horizon verification tests ===== + + @Test + fun `verifyPurchaseWithHorizon throws without horizon options`() = runTest { + val props = VerifyPurchaseProps(sku = "50_gems") + + try { + verifyPurchaseWithHorizon(props, "test-app-id", "TEST") { _ -> + throw AssertionError("Connection should not be created when horizon options are missing") + } + throw AssertionError("Expected IllegalArgumentException for missing horizon options") + } catch (expected: IllegalArgumentException) { + // Expected path + } + } + + @Test + fun `verifyPurchaseWithHorizon parses successful response`() = runTest { + val horizonOptions = VerifyPurchaseHorizonOptions( + sku = "50_gems", + userId = "123456789", + accessToken = "OC|app_id|app_secret" + ) + val props = VerifyPurchaseProps(horizon = horizonOptions, sku = "50_gems") + + val result = verifyPurchaseWithHorizon( + props, + "test-app-id", + "TEST" + ) { _ -> FakeHttpURLConnection(200, """{"success":true,"grant_time":1744148687}""") } + + assertEquals(true, result.success) + assertEquals(1744148687.0, result.grantTime) + } + + @Test + fun `verifyPurchaseWithHorizon throws on failure response`() = runTest { + val horizonOptions = VerifyPurchaseHorizonOptions( + sku = "50_gems", + userId = "123456789", + accessToken = "OC|app_id|app_secret" + ) + val props = VerifyPurchaseProps(horizon = horizonOptions, sku = "50_gems") + + try { + verifyPurchaseWithHorizon( + props, + "test-app-id", + "TEST" + ) { _ -> FakeHttpURLConnection(400, """{"error":"invalid_user"}""") } + throw AssertionError("Expected InvalidPurchaseVerification for non-2xx response") + } catch (error: OpenIapError.InvalidPurchaseVerification) { + assertTrue(true) + } + } + } private class FakeHttpURLConnection( diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index cd1b9968..a060f318 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -2408,6 +2408,37 @@ public data class VerifyPurchaseResultAndroid( ) } +/** + * Result from Meta Horizon verify_entitlement API. + * Returns verification status and grant time for the entitlement. + */ +public data class VerifyPurchaseResultHorizon( + /** + * Unix timestamp (seconds) when the entitlement was granted. + */ + val grantTime: Double? = null, + /** + * Whether the entitlement verification succeeded. + */ + val success: Boolean +) : VerifyPurchaseResult { + + companion object { + fun fromJson(json: Map): VerifyPurchaseResultHorizon { + return VerifyPurchaseResultHorizon( + grantTime = (json["grantTime"] as Number?)?.toDouble(), + success = json["success"] as Boolean, + ) + } + } + + override fun toJson(): Map = mapOf( + "__typename" to "VerifyPurchaseResultHorizon", + "grantTime" to grantTime, + "success" to success, + ) +} + public data class VerifyPurchaseResultIOS( /** * Whether the receipt is valid @@ -2839,6 +2870,14 @@ public data class RequestPurchaseProps( } } +/** + * Platform-specific purchase request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ public data class RequestPurchasePropsByPlatforms( /** * @deprecated Use google instead @@ -2967,6 +3006,14 @@ public data class RequestSubscriptionIosProps( ) } +/** + * Platform-specific subscription request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ public data class RequestSubscriptionPropsByPlatforms( /** * @deprecated Use google instead @@ -3042,17 +3089,23 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps( ) } +/** + * Platform-specific verification parameters for IAPKit. + * + * - apple: Verifies via App Store (JWS token) + * - google: Verifies via Play Store (purchase token) + */ public data class RequestVerifyPurchaseWithIapkitProps( /** * API key used for the Authorization header (Bearer {apiKey}). */ val apiKey: String? = null, /** - * Apple verification parameters. + * Apple App Store verification parameters. */ val apple: RequestVerifyPurchaseWithIapkitAppleProps? = null, /** - * Google verification parameters. + * Google Play Store verification parameters. */ val google: RequestVerifyPurchaseWithIapkitGoogleProps? = null ) { @@ -3103,19 +3156,65 @@ public data class SubscriptionProductReplacementParamsAndroid( ) } -public data class VerifyPurchaseAndroidOptions( +/** + * Apple App Store verification parameters. + * Used for server-side receipt validation via App Store Server API. + * + * ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. + */ +public data class VerifyPurchaseAppleOptions( + /** + * The JWS (JSON Web Signature) representation of the transaction. + * ⚠️ Sensitive: Do not log this value. + */ + val jws: String +) { + companion object { + fun fromJson(json: Map): VerifyPurchaseAppleOptions { + return VerifyPurchaseAppleOptions( + jws = json["jws"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "jws" to jws, + ) +} + +/** + * Google Play Store verification parameters. + * Used for server-side receipt validation via Google Play Developer API. + * + * ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. + */ +public data class VerifyPurchaseGoogleOptions( + /** + * Google OAuth2 access token for API authentication. + * ⚠️ Sensitive: Do not log this value. + */ val accessToken: String, + /** + * Whether this is a subscription purchase (affects API endpoint used) + */ val isSub: Boolean? = null, + /** + * Android package name (e.g., com.example.app) + */ val packageName: String, - val productToken: String + /** + * Purchase token from the purchase response. + * ⚠️ Sensitive: Do not log this value. + */ + val purchaseToken: String ) { companion object { - fun fromJson(json: Map): VerifyPurchaseAndroidOptions { - return VerifyPurchaseAndroidOptions( + fun fromJson(json: Map): VerifyPurchaseGoogleOptions { + return VerifyPurchaseGoogleOptions( accessToken = json["accessToken"] as String, isSub = json["isSub"] as Boolean?, packageName = json["packageName"] as String, - productToken = json["productToken"] as String, + purchaseToken = json["purchaseToken"] as String, ) } } @@ -3124,15 +3223,69 @@ public data class VerifyPurchaseAndroidOptions( "accessToken" to accessToken, "isSub" to isSub, "packageName" to packageName, - "productToken" to productToken, + "purchaseToken" to purchaseToken, + ) +} + +/** + * Meta Horizon (Quest) verification parameters. + * Used for server-side entitlement verification via Meta's S2S API. + * POST https://graph.oculus.com/$APP_ID/verify_entitlement + * + * ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. + */ +public data class VerifyPurchaseHorizonOptions( + /** + * Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + * ⚠️ Sensitive: Do not log this value. + */ + val accessToken: String, + /** + * The SKU for the add-on item, defined in Meta Developer Dashboard + */ + val sku: String, + /** + * The user ID of the user whose purchase you want to verify + */ + val userId: String +) { + companion object { + fun fromJson(json: Map): VerifyPurchaseHorizonOptions { + return VerifyPurchaseHorizonOptions( + accessToken = json["accessToken"] as String, + sku = json["sku"] as String, + userId = json["userId"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "accessToken" to accessToken, + "sku" to sku, + "userId" to userId, ) } +/** + * Platform-specific purchase verification parameters. + * + * - apple: Verifies via App Store Server API + * - google: Verifies via Google Play Developer API + * - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) + */ public data class VerifyPurchaseProps( /** - * Android-specific validation options + * Apple App Store verification parameters. */ - val androidOptions: VerifyPurchaseAndroidOptions? = null, + val apple: VerifyPurchaseAppleOptions? = null, + /** + * Google Play Store verification parameters. + */ + val google: VerifyPurchaseGoogleOptions? = null, + /** + * Meta Horizon (Quest) verification parameters. + */ + val horizon: VerifyPurchaseHorizonOptions? = null, /** * Product SKU to validate */ @@ -3141,14 +3294,18 @@ public data class VerifyPurchaseProps( companion object { fun fromJson(json: Map): VerifyPurchaseProps { return VerifyPurchaseProps( - androidOptions = (json["androidOptions"] as Map?)?.let { VerifyPurchaseAndroidOptions.fromJson(it) }, + apple = (json["apple"] as Map?)?.let { VerifyPurchaseAppleOptions.fromJson(it) }, + google = (json["google"] as Map?)?.let { VerifyPurchaseGoogleOptions.fromJson(it) }, + horizon = (json["horizon"] as Map?)?.let { VerifyPurchaseHorizonOptions.fromJson(it) }, sku = json["sku"] as String, ) } } fun toJson(): Map = mapOf( - "androidOptions" to androidOptions?.toJson(), + "apple" to apple?.toJson(), + "google" to google?.toJson(), + "horizon" to horizon?.toJson(), "sku" to sku, ) } @@ -3247,6 +3404,7 @@ public sealed interface VerifyPurchaseResult { fun fromJson(json: Map): VerifyPurchaseResult { return when (json["__typename"] as String?) { "VerifyPurchaseResultAndroid" -> VerifyPurchaseResultAndroid.fromJson(json) + "VerifyPurchaseResultHorizon" -> VerifyPurchaseResultHorizon.fromJson(json) "VerifyPurchaseResultIOS" -> VerifyPurchaseResultIOS.fromJson(json) else -> throw IllegalArgumentException("Unknown __typename for VerifyPurchaseResult: ${json["__typename"]}") } diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index e4681362..42365f34 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -844,6 +844,15 @@ public struct VerifyPurchaseResultAndroid: Codable { public var testTransaction: Bool } +/// Result from Meta Horizon verify_entitlement API. +/// Returns verification status and grant time for the entitlement. +public struct VerifyPurchaseResultHorizon: Codable { + /// Unix timestamp (seconds) when the entitlement was granted. + public var grantTime: Double? + /// Whether the entitlement verification succeeded. + public var success: Bool +} + public struct VerifyPurchaseResultIOS: Codable { /// Whether the receipt is valid public var isValid: Bool @@ -1142,6 +1151,12 @@ public struct RequestPurchaseProps: Codable { } } +/// Platform-specific purchase request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) public struct RequestPurchasePropsByPlatforms: Codable { /// @deprecated Use google instead public var android: RequestPurchaseAndroidProps? @@ -1228,6 +1243,12 @@ public struct RequestSubscriptionIosProps: Codable { } } +/// Platform-specific subscription request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) public struct RequestSubscriptionPropsByPlatforms: Codable { /// @deprecated Use google instead public var android: RequestSubscriptionAndroidProps? @@ -1273,12 +1294,16 @@ public struct RequestVerifyPurchaseWithIapkitGoogleProps: Codable { } } +/// Platform-specific verification parameters for IAPKit. +/// +/// - apple: Verifies via App Store (JWS token) +/// - google: Verifies via Play Store (purchase token) public struct RequestVerifyPurchaseWithIapkitProps: Codable { /// API key used for the Authorization header (Bearer {apiKey}). public var apiKey: String? - /// Apple verification parameters. + /// Apple App Store verification parameters. public var apple: RequestVerifyPurchaseWithIapkitAppleProps? - /// Google verification parameters. + /// Google Play Store verification parameters. public var google: RequestVerifyPurchaseWithIapkitGoogleProps? public init( @@ -1310,36 +1335,100 @@ public struct SubscriptionProductReplacementParamsAndroid: Codable { } } -public struct VerifyPurchaseAndroidOptions: Codable { +/// Apple App Store verification parameters. +/// Used for server-side receipt validation via App Store Server API. +/// +/// ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. +public struct VerifyPurchaseAppleOptions: Codable { + /// The JWS (JSON Web Signature) representation of the transaction. + /// ⚠️ Sensitive: Do not log this value. + public var jws: String + + public init( + jws: String + ) { + self.jws = jws + } +} + +/// Google Play Store verification parameters. +/// Used for server-side receipt validation via Google Play Developer API. +/// +/// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +public struct VerifyPurchaseGoogleOptions: Codable { + /// Google OAuth2 access token for API authentication. + /// ⚠️ Sensitive: Do not log this value. public var accessToken: String + /// Whether this is a subscription purchase (affects API endpoint used) public var isSub: Bool? + /// Android package name (e.g., com.example.app) public var packageName: String - public var productToken: String + /// Purchase token from the purchase response. + /// ⚠️ Sensitive: Do not log this value. + public var purchaseToken: String public init( accessToken: String, isSub: Bool? = nil, packageName: String, - productToken: String + purchaseToken: String ) { self.accessToken = accessToken self.isSub = isSub self.packageName = packageName - self.productToken = productToken + self.purchaseToken = purchaseToken } } +/// Meta Horizon (Quest) verification parameters. +/// Used for server-side entitlement verification via Meta's S2S API. +/// POST https://graph.oculus.com/$APP_ID/verify_entitlement +/// +/// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +public struct VerifyPurchaseHorizonOptions: Codable { + /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + /// ⚠️ Sensitive: Do not log this value. + public var accessToken: String + /// The SKU for the add-on item, defined in Meta Developer Dashboard + public var sku: String + /// The user ID of the user whose purchase you want to verify + public var userId: String + + public init( + accessToken: String, + sku: String, + userId: String + ) { + self.accessToken = accessToken + self.sku = sku + self.userId = userId + } +} + +/// Platform-specific purchase verification parameters. +/// +/// - apple: Verifies via App Store Server API +/// - google: Verifies via Google Play Developer API +/// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) public struct VerifyPurchaseProps: Codable { - /// Android-specific validation options - public var androidOptions: VerifyPurchaseAndroidOptions? + /// Apple App Store verification parameters. + public var apple: VerifyPurchaseAppleOptions? + /// Google Play Store verification parameters. + public var google: VerifyPurchaseGoogleOptions? + /// Meta Horizon (Quest) verification parameters. + public var horizon: VerifyPurchaseHorizonOptions? /// Product SKU to validate public var sku: String public init( - androidOptions: VerifyPurchaseAndroidOptions? = nil, + apple: VerifyPurchaseAppleOptions? = nil, + google: VerifyPurchaseGoogleOptions? = nil, + horizon: VerifyPurchaseHorizonOptions? = nil, sku: String ) { - self.androidOptions = androidOptions + self.apple = apple + self.google = google + self.horizon = horizon self.sku = sku } } @@ -1667,6 +1756,7 @@ public enum Purchase: Codable, PurchaseCommon { public enum VerifyPurchaseResult: Codable { case verifyPurchaseResultAndroid(VerifyPurchaseResultAndroid) case verifyPurchaseResultIos(VerifyPurchaseResultIOS) + case verifyPurchaseResultHorizon(VerifyPurchaseResultHorizon) } // MARK: - Root Operations diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index b79e0451..ecde6bfe 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -2807,6 +2807,38 @@ class VerifyPurchaseResultAndroid extends VerifyPurchaseResult { } } +/// Result from Meta Horizon verify_entitlement API. +/// Returns verification status and grant time for the entitlement. +class VerifyPurchaseResultHorizon extends VerifyPurchaseResult { + const VerifyPurchaseResultHorizon({ + /// Unix timestamp (seconds) when the entitlement was granted. + this.grantTime, + /// Whether the entitlement verification succeeded. + required this.success, + }); + + /// Unix timestamp (seconds) when the entitlement was granted. + final double? grantTime; + /// Whether the entitlement verification succeeded. + final bool success; + + factory VerifyPurchaseResultHorizon.fromJson(Map json) { + return VerifyPurchaseResultHorizon( + grantTime: (json['grantTime'] as num?)?.toDouble(), + success: json['success'] as bool, + ); + } + + @override + Map toJson() { + return { + '__typename': 'VerifyPurchaseResultHorizon', + 'grantTime': grantTime, + 'success': success, + }; + } +} + class VerifyPurchaseResultIOS extends VerifyPurchaseResult { const VerifyPurchaseResultIOS({ /// Whether the receipt is valid @@ -3281,6 +3313,12 @@ class _SubsPurchase extends RequestPurchaseProps { } } +/// Platform-specific purchase request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) class RequestPurchasePropsByPlatforms { const RequestPurchasePropsByPlatforms({ /// @deprecated Use google instead @@ -3425,6 +3463,12 @@ class RequestSubscriptionIosProps { } } +/// Platform-specific subscription request parameters. +/// +/// Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +/// - apple: Always targets App Store +/// - google: Targets Play Store by default, or Horizon when built with horizon flavor +/// (determined at build time, not runtime) class RequestSubscriptionPropsByPlatforms { const RequestSubscriptionPropsByPlatforms({ /// @deprecated Use google instead @@ -3509,21 +3553,25 @@ class RequestVerifyPurchaseWithIapkitGoogleProps { } } +/// Platform-specific verification parameters for IAPKit. +/// +/// - apple: Verifies via App Store (JWS token) +/// - google: Verifies via Play Store (purchase token) class RequestVerifyPurchaseWithIapkitProps { const RequestVerifyPurchaseWithIapkitProps({ /// API key used for the Authorization header (Bearer {apiKey}). this.apiKey, - /// Apple verification parameters. + /// Apple App Store verification parameters. this.apple, - /// Google verification parameters. + /// Google Play Store verification parameters. this.google, }); /// API key used for the Authorization header (Bearer {apiKey}). final String? apiKey; - /// Apple verification parameters. + /// Apple App Store verification parameters. final RequestVerifyPurchaseWithIapkitAppleProps? apple; - /// Google verification parameters. + /// Google Play Store verification parameters. final RequestVerifyPurchaseWithIapkitGoogleProps? google; factory RequestVerifyPurchaseWithIapkitProps.fromJson(Map json) { @@ -3574,25 +3622,69 @@ class SubscriptionProductReplacementParamsAndroid { } } -class VerifyPurchaseAndroidOptions { - const VerifyPurchaseAndroidOptions({ +/// Apple App Store verification parameters. +/// Used for server-side receipt validation via App Store Server API. +/// +/// ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. +class VerifyPurchaseAppleOptions { + const VerifyPurchaseAppleOptions({ + /// The JWS (JSON Web Signature) representation of the transaction. + /// ⚠️ Sensitive: Do not log this value. + required this.jws, + }); + + /// The JWS (JSON Web Signature) representation of the transaction. + /// ⚠️ Sensitive: Do not log this value. + final String jws; + + factory VerifyPurchaseAppleOptions.fromJson(Map json) { + return VerifyPurchaseAppleOptions( + jws: json['jws'] as String, + ); + } + + Map toJson() { + return { + 'jws': jws, + }; + } +} + +/// Google Play Store verification parameters. +/// Used for server-side receipt validation via Google Play Developer API. +/// +/// ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +class VerifyPurchaseGoogleOptions { + const VerifyPurchaseGoogleOptions({ + /// Google OAuth2 access token for API authentication. + /// ⚠️ Sensitive: Do not log this value. required this.accessToken, + /// Whether this is a subscription purchase (affects API endpoint used) this.isSub, + /// Android package name (e.g., com.example.app) required this.packageName, - required this.productToken, + /// Purchase token from the purchase response. + /// ⚠️ Sensitive: Do not log this value. + required this.purchaseToken, }); + /// Google OAuth2 access token for API authentication. + /// ⚠️ Sensitive: Do not log this value. final String accessToken; + /// Whether this is a subscription purchase (affects API endpoint used) final bool? isSub; + /// Android package name (e.g., com.example.app) final String packageName; - final String productToken; + /// Purchase token from the purchase response. + /// ⚠️ Sensitive: Do not log this value. + final String purchaseToken; - factory VerifyPurchaseAndroidOptions.fromJson(Map json) { - return VerifyPurchaseAndroidOptions( + factory VerifyPurchaseGoogleOptions.fromJson(Map json) { + return VerifyPurchaseGoogleOptions( accessToken: json['accessToken'] as String, isSub: json['isSub'] as bool?, packageName: json['packageName'] as String, - productToken: json['productToken'] as String, + purchaseToken: json['purchaseToken'] as String, ); } @@ -3601,34 +3693,92 @@ class VerifyPurchaseAndroidOptions { 'accessToken': accessToken, 'isSub': isSub, 'packageName': packageName, - 'productToken': productToken, + 'purchaseToken': purchaseToken, + }; + } +} + +/// Meta Horizon (Quest) verification parameters. +/// Used for server-side entitlement verification via Meta's S2S API. +/// POST https://graph.oculus.com/$APP_ID/verify_entitlement +/// +/// ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +class VerifyPurchaseHorizonOptions { + const VerifyPurchaseHorizonOptions({ + /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + /// ⚠️ Sensitive: Do not log this value. + required this.accessToken, + /// The SKU for the add-on item, defined in Meta Developer Dashboard + required this.sku, + /// The user ID of the user whose purchase you want to verify + required this.userId, + }); + + /// Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + /// ⚠️ Sensitive: Do not log this value. + final String accessToken; + /// The SKU for the add-on item, defined in Meta Developer Dashboard + final String sku; + /// The user ID of the user whose purchase you want to verify + final String userId; + + factory VerifyPurchaseHorizonOptions.fromJson(Map json) { + return VerifyPurchaseHorizonOptions( + accessToken: json['accessToken'] as String, + sku: json['sku'] as String, + userId: json['userId'] as String, + ); + } + + Map toJson() { + return { + 'accessToken': accessToken, + 'sku': sku, + 'userId': userId, }; } } +/// Platform-specific purchase verification parameters. +/// +/// - apple: Verifies via App Store Server API +/// - google: Verifies via Google Play Developer API +/// - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) class VerifyPurchaseProps { const VerifyPurchaseProps({ - /// Android-specific validation options - this.androidOptions, + /// Apple App Store verification parameters. + this.apple, + /// Google Play Store verification parameters. + this.google, + /// Meta Horizon (Quest) verification parameters. + this.horizon, /// Product SKU to validate required this.sku, }); - /// Android-specific validation options - final VerifyPurchaseAndroidOptions? androidOptions; + /// Apple App Store verification parameters. + final VerifyPurchaseAppleOptions? apple; + /// Google Play Store verification parameters. + final VerifyPurchaseGoogleOptions? google; + /// Meta Horizon (Quest) verification parameters. + final VerifyPurchaseHorizonOptions? horizon; /// Product SKU to validate final String sku; factory VerifyPurchaseProps.fromJson(Map json) { return VerifyPurchaseProps( - androidOptions: json['androidOptions'] != null ? VerifyPurchaseAndroidOptions.fromJson(json['androidOptions'] as Map) : null, + apple: json['apple'] != null ? VerifyPurchaseAppleOptions.fromJson(json['apple'] as Map) : null, + google: json['google'] != null ? VerifyPurchaseGoogleOptions.fromJson(json['google'] as Map) : null, + horizon: json['horizon'] != null ? VerifyPurchaseHorizonOptions.fromJson(json['horizon'] as Map) : null, sku: json['sku'] as String, ); } Map toJson() { return { - 'androidOptions': androidOptions?.toJson(), + 'apple': apple?.toJson(), + 'google': google?.toJson(), + 'horizon': horizon?.toJson(), 'sku': sku, }; } @@ -3827,6 +3977,8 @@ sealed class VerifyPurchaseResult { switch (typeName) { case 'VerifyPurchaseResultAndroid': return VerifyPurchaseResultAndroid.fromJson(json); + case 'VerifyPurchaseResultHorizon': + return VerifyPurchaseResultHorizon.fromJson(json); case 'VerifyPurchaseResultIOS': return VerifyPurchaseResultIOS.fromJson(json); } @@ -3903,12 +4055,16 @@ abstract class MutationResolver { Future syncIOS(); /// Validate purchase receipts with the configured providers Future validateReceipt({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); /// Verify purchases with the configured providers Future verifyPurchase({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); /// Verify purchases with a specific provider (e.g., IAPKit) @@ -3962,7 +4118,9 @@ abstract class QueryResolver { Future> subscriptionStatusIOS(String sku); /// Validate a receipt for a specific product Future validateReceiptIOS({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); } @@ -4012,11 +4170,15 @@ typedef MutationShowAlternativeBillingDialogAndroidHandler = Future Functi typedef MutationShowManageSubscriptionsIOSHandler = Future> Function(); typedef MutationSyncIOSHandler = Future Function(); typedef MutationValidateReceiptHandler = Future Function({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); typedef MutationVerifyPurchaseHandler = Future Function({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); typedef MutationVerifyPurchaseWithProviderHandler = Future Function({ @@ -4100,7 +4262,9 @@ typedef QueryIsTransactionVerifiedIOSHandler = Future Function(String sku) typedef QueryLatestTransactionIOSHandler = Future Function(String sku); typedef QuerySubscriptionStatusIOSHandler = Future> Function(String sku); typedef QueryValidateReceiptIOSHandler = Future Function({ - VerifyPurchaseAndroidOptions? androidOptions, + VerifyPurchaseAppleOptions? apple, + VerifyPurchaseGoogleOptions? google, + VerifyPurchaseHorizonOptions? horizon, required String sku, }); diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index bdd4ac65..2f7ebf1a 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -917,6 +917,14 @@ export type RequestPurchaseProps = useAlternativeBilling?: boolean | null; }; +/** + * Platform-specific purchase request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ export interface RequestPurchasePropsByPlatforms { /** @deprecated Use google instead */ android?: (RequestPurchaseAndroidProps | null); @@ -963,6 +971,14 @@ export interface RequestSubscriptionIosProps { withOffer?: (DiscountOfferInputIOS | null); } +/** + * Platform-specific subscription request parameters. + * + * Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. + * - apple: Always targets App Store + * - google: Targets Play Store by default, or Horizon when built with horizon flavor + * (determined at build time, not runtime) + */ export interface RequestSubscriptionPropsByPlatforms { /** @deprecated Use google instead */ android?: (RequestSubscriptionAndroidProps | null); @@ -984,12 +1000,18 @@ export interface RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: string; } +/** + * Platform-specific verification parameters for IAPKit. + * + * - apple: Verifies via App Store (JWS token) + * - google: Verifies via Play Store (purchase token) + */ export interface RequestVerifyPurchaseWithIapkitProps { /** API key used for the Authorization header (Bearer {apiKey}). */ apiKey?: (string | null); - /** Apple verification parameters. */ + /** Apple App Store verification parameters. */ apple?: (RequestVerifyPurchaseWithIapkitAppleProps | null); - /** Google verification parameters. */ + /** Google Play Store verification parameters. */ google?: (RequestVerifyPurchaseWithIapkitGoogleProps | null); } @@ -1088,21 +1110,81 @@ export interface ValidTimeWindowAndroid { startTimeMillis: string; } -export interface VerifyPurchaseAndroidOptions { +/** + * Apple App Store verification parameters. + * Used for server-side receipt validation via App Store Server API. + * + * ⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. + */ +export interface VerifyPurchaseAppleOptions { + /** + * The JWS (JSON Web Signature) representation of the transaction. + * ⚠️ Sensitive: Do not log this value. + */ + jws: string; +} + +/** + * Google Play Store verification parameters. + * Used for server-side receipt validation via Google Play Developer API. + * + * ⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. + */ +export interface VerifyPurchaseGoogleOptions { + /** + * Google OAuth2 access token for API authentication. + * ⚠️ Sensitive: Do not log this value. + */ accessToken: string; + /** Whether this is a subscription purchase (affects API endpoint used) */ isSub?: (boolean | null); + /** Android package name (e.g., com.example.app) */ packageName: string; - productToken: string; + /** + * Purchase token from the purchase response. + * ⚠️ Sensitive: Do not log this value. + */ + purchaseToken: string; } +/** + * Meta Horizon (Quest) verification parameters. + * Used for server-side entitlement verification via Meta's S2S API. + * POST https://graph.oculus.com/$APP_ID/verify_entitlement + * + * ⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. + */ +export interface VerifyPurchaseHorizonOptions { + /** + * Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + * ⚠️ Sensitive: Do not log this value. + */ + accessToken: string; + /** The SKU for the add-on item, defined in Meta Developer Dashboard */ + sku: string; + /** The user ID of the user whose purchase you want to verify */ + userId: string; +} + +/** + * Platform-specific purchase verification parameters. + * + * - apple: Verifies via App Store Server API + * - google: Verifies via Google Play Developer API + * - horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) + */ export interface VerifyPurchaseProps { - /** Android-specific validation options */ - androidOptions?: (VerifyPurchaseAndroidOptions | null); + /** Apple App Store verification parameters. */ + apple?: (VerifyPurchaseAppleOptions | null); + /** Google Play Store verification parameters. */ + google?: (VerifyPurchaseGoogleOptions | null); + /** Meta Horizon (Quest) verification parameters. */ + horizon?: (VerifyPurchaseHorizonOptions | null); /** Product SKU to validate */ sku: string; } -export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultIOS; +export type VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultHorizon | VerifyPurchaseResultIOS; export interface VerifyPurchaseResultAndroid { autoRenewing: boolean; @@ -1125,6 +1207,17 @@ export interface VerifyPurchaseResultAndroid { testTransaction: boolean; } +/** + * Result from Meta Horizon verify_entitlement API. + * Returns verification status and grant time for the entitlement. + */ +export interface VerifyPurchaseResultHorizon { + /** Unix timestamp (seconds) when the entitlement was granted. */ + grantTime?: (number | null); + /** Whether the entitlement verification succeeded. */ + success: boolean; +} + export interface VerifyPurchaseResultIOS { /** Whether the receipt is valid */ isValid: boolean; diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index 7503c0a3..57a9175b 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -370,13 +370,71 @@ input AndroidSubscriptionOfferInput { offerToken: String! } -input VerifyPurchaseAndroidOptions { +""" +Google Play Store verification parameters. +Used for server-side receipt validation via Google Play Developer API. + +⚠️ SECURITY: Contains sensitive tokens (accessToken, purchaseToken). Do not log or persist this data. +""" +input VerifyPurchaseGoogleOptions { + """ + Android package name (e.g., com.example.app) + """ packageName: String! - productToken: String! + """ + Purchase token from the purchase response. + ⚠️ Sensitive: Do not log this value. + """ + purchaseToken: String! + """ + Google OAuth2 access token for API authentication. + ⚠️ Sensitive: Do not log this value. + """ accessToken: String! + """ + Whether this is a subscription purchase (affects API endpoint used) + """ isSub: Boolean } +""" +Meta Horizon (Quest) verification parameters. +Used for server-side entitlement verification via Meta's S2S API. +POST https://graph.oculus.com/$APP_ID/verify_entitlement + +⚠️ SECURITY: Contains sensitive token (accessToken). Do not log or persist this data. +""" +input VerifyPurchaseHorizonOptions { + """ + The SKU for the add-on item, defined in Meta Developer Dashboard + """ + sku: String! + """ + The user ID of the user whose purchase you want to verify + """ + userId: String! + """ + Access token for Meta API authentication (OC|$APP_ID|$APP_SECRET or User Access Token). + ⚠️ Sensitive: Do not log this value. + """ + accessToken: String! +} + +""" +Result from Meta Horizon verify_entitlement API. +Returns verification status and grant time for the entitlement. +""" +type VerifyPurchaseResultHorizon { + """ + Whether the entitlement verification succeeded. + """ + success: Boolean! + """ + Unix timestamp (seconds) when the entitlement was granted. + """ + grantTime: Float +} + type VerifyPurchaseResultAndroid { autoRenewing: Boolean! betaProduct: Boolean! diff --git a/packages/gql/src/type-ios.graphql b/packages/gql/src/type-ios.graphql index daed31ce..d4b036a1 100644 --- a/packages/gql/src/type-ios.graphql +++ b/packages/gql/src/type-ios.graphql @@ -227,6 +227,20 @@ input DiscountOfferInputIOS { timestamp: Float! } +""" +Apple App Store verification parameters. +Used for server-side receipt validation via App Store Server API. + +⚠️ SECURITY: Contains sensitive token (jws). Do not log or persist this data. +""" +input VerifyPurchaseAppleOptions { + """ + The JWS (JSON Web Signature) representation of the transaction. + ⚠️ Sensitive: Do not log this value. + """ + jws: String! +} + type VerifyPurchaseResultIOS { """ Whether the receipt is valid diff --git a/packages/gql/src/type.graphql b/packages/gql/src/type.graphql index 65ce1f20..08e57323 100644 --- a/packages/gql/src/type.graphql +++ b/packages/gql/src/type.graphql @@ -187,7 +187,14 @@ input DeepLinkOptions { packageNameAndroid: String } -# Request props (platform-specific containers) +""" +Platform-specific purchase request parameters. + +Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +- apple: Always targets App Store +- google: Targets Play Store by default, or Horizon when built with horizon flavor + (determined at build time, not runtime) +""" input RequestPurchasePropsByPlatforms { """ Apple-specific purchase parameters @@ -207,6 +214,14 @@ input RequestPurchasePropsByPlatforms { android: RequestPurchaseAndroidProps } +""" +Platform-specific subscription request parameters. + +Note: "Platforms" refers to the SDK/OS level (apple, google), not the store. +- apple: Always targets App Store +- google: Targets Play Store by default, or Horizon when built with horizon flavor + (determined at build time, not runtime) +""" input RequestSubscriptionPropsByPlatforms { """ Apple-specific subscription parameters @@ -227,20 +242,36 @@ input RequestSubscriptionPropsByPlatforms { } # Receipt validation inputs and results +""" +Platform-specific purchase verification parameters. + +- apple: Verifies via App Store Server API +- google: Verifies via Google Play Developer API +- horizon: Verifies via Meta's S2S API (verify_entitlement endpoint) +""" input VerifyPurchaseProps { """ Product SKU to validate """ sku: String! """ - Android-specific validation options + Apple App Store verification parameters. + """ + apple: VerifyPurchaseAppleOptions + """ + Google Play Store verification parameters. + """ + google: VerifyPurchaseGoogleOptions """ - androidOptions: VerifyPurchaseAndroidOptions + Meta Horizon (Quest) verification parameters. + """ + horizon: VerifyPurchaseHorizonOptions } union VerifyPurchaseResult = VerifyPurchaseResultAndroid | VerifyPurchaseResultIOS + | VerifyPurchaseResultHorizon input RequestVerifyPurchaseWithIapkitAppleProps { """ @@ -256,17 +287,23 @@ input RequestVerifyPurchaseWithIapkitGoogleProps { purchaseToken: String! } +""" +Platform-specific verification parameters for IAPKit. + +- apple: Verifies via App Store (JWS token) +- google: Verifies via Play Store (purchase token) +""" input RequestVerifyPurchaseWithIapkitProps { """ API key used for the Authorization header (Bearer {apiKey}). """ apiKey: String """ - Apple verification parameters. + Apple App Store verification parameters. """ apple: RequestVerifyPurchaseWithIapkitAppleProps """ - Google verification parameters. + Google Play Store verification parameters. """ google: RequestVerifyPurchaseWithIapkitGoogleProps }