From f3647fb441e956105c7ff63d05acbef502f13dab Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:11:55 +0800 Subject: [PATCH] feat: add public key support --- README.md | 50 ++++- Sources/RxSubscriptionIOS/Client.swift | 102 +++++++++- .../RxSubscriptionIOS/RxSubscriptionIOS.swift | 2 +- .../RxSubscriptionIOS/StoreKitSupport.swift | 39 ++++ .../PublishableKeyTests.swift | 192 ++++++++++++++++++ 5 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 Tests/RxSubscriptionIOSTests/PublishableKeyTests.swift diff --git a/README.md b/README.md index 2e4320d..5c666b4 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,15 @@ - reusable SwiftUI plan, top-up, usage, balance, and balance-history screens; - a section-selectable paywall with a host-app supplied SwiftUI header. -The package supports iOS 16+, macOS 13+, and Swift 5.9+. +The package requires iOS 26+, macOS 26+, and Swift 5.9+. ## Add the package -In Xcode, choose **File → Add Package Dependencies → Add Local…** and select this folder. Import the library where it is used: +```swift +.package(url: "https://github.com/rxtech-lab/RxSubscriptionIOS.git", from: "0.2.0") +``` + +Or in Xcode, **File → Add Package Dependencies…** with that URL. Import the library where it is used: ```swift import RxSubscriptionIOS @@ -19,7 +23,31 @@ import RxSubscriptionIOS ## Configure a client -Create one client for the currently signed-in RxLab user. The API key controls whether the server uses sandbox or production data. +Create one client for the currently signed-in RxLab user. The key controls whether the server uses sandbox or production data. + +Which initializer you want depends on the kind of key you hold. **In an app, use a publishable key.** + +### Publishable key — for apps + +A publishable key is safe to ship inside a binary because it does nothing on its own. Every request also carries the signed-in user's rxlab access token, and the server acts only for whoever that token identifies — so a key lifted out of your app grants an attacker nothing they did not already have. + +```swift +let subscriptions = Client( + serverURL: URL(string: "https://subscription.example.com")!, + publishableKey: configuration.subscriptionPublishableKey, + rxlabUserID: session.userID, + email: session.email, + userToken: { forceRefresh in + try await session.accessToken(forceRefresh: forceRefresh) + } +) +``` + +The `userToken` closure is called before every request, and called again with `forceRefresh: true` if the server rejects the token — so an access token that expired while a screen sat open recovers without the user noticing. Your app's existing session machinery stays the only thing that knows how to refresh. + +Publishable keys reach the read and purchase endpoints: catalog, entitlements, usage, balances, ledger, consumption, invoices, purchases, coupon validation, checkout, and the App Store bridge. Crediting a balance, recording usage, and the whole reservation family answer `403 insufficient_key_scope` — those belong on a server. + +### Secret key — for servers ```swift let subscriptions = Client( @@ -31,7 +59,7 @@ let subscriptions = Client( ) ``` -Do not place an unrestricted or unrelated server credential in an app bundle. Mobile app secrets can be extracted. Use a dedicated, revocable application key for this backend contract and rotate it if the app is compromised. +A secret key reaches every endpoint and names whichever user it likes, so it must never ship in an app bundle — mobile app secrets can be extracted, and this one can credit any balance for any user. ## SwiftUI views @@ -97,6 +125,16 @@ let outcome = try await subscriptions.purchaseApple( let restored = try await subscriptions.restoreApplePurchases() ``` +Start `observeTransactionUpdates()` once at launch and hold the task for the lifetime of the session: + +```swift +transactionObserver = subscriptions.observeTransactionUpdates { _ in + Task { await store.refresh() } +} +``` + +Renewals, Ask-to-Buy approvals, purchases made on another device, and interrupted flows all arrive on `Transaction.updates` rather than as the result of `purchaseApple`. Without an observer they are never finished, so StoreKit re-delivers them on every launch. The backend still learns about them from App Store Server Notifications either way; this just keeps the app in step. + Enable the **In-App Purchase** capability in the containing app target. Products and Notifications V2 still need to be configured in App Store Connect and in the RxSubscription console. ## Client API @@ -112,7 +150,7 @@ The client covers the backend's complete public API surface. | Stripe | `checkoutPlan`, `checkoutTopUp`, `billingPortal`, `validateCoupon`, `invoices` | | Purchases | `purchases` | | App Store bridge | `appleAccountToken`, `setAppleConsumptionConsent`, `submitAppleTransaction` | -| StoreKit | `storeProducts`, `purchaseApple`, `restoreApplePurchases` | +| StoreKit | `storeProducts`, `purchaseApple`, `restoreApplePurchases`, `observeTransactionUpdates` | Metadata values use the package's `JSONValue` type. Balance and usage mutations preserve the server's idempotency contract. In particular, supply stable idempotency keys when retrying balance mutations or reservation operations. @@ -145,4 +183,4 @@ The backend returns usage denials with HTTP 402. `recordUsage` decodes those res swift test ``` -The test suite verifies authentication and user scoping, StoreKit catalog decoding, error payloads, HTTP 402 usage behavior, date handling, formatting, and request/response coverage for every public backend route. +The test suite verifies authentication and user scoping, StoreKit catalog decoding, error payloads, HTTP 402 usage behavior, date handling, formatting, and request/response coverage for every public backend route. It also covers the publishable-key path: that both credentials are sent, that a 401 triggers exactly one refreshed retry and no more, that a secret-key client is never retried, and that a failed token lookup surfaces as `ClientError.userTokenUnavailable` rather than as a network error. diff --git a/Sources/RxSubscriptionIOS/Client.swift b/Sources/RxSubscriptionIOS/Client.swift index d1776da..de00db7 100644 --- a/Sources/RxSubscriptionIOS/Client.swift +++ b/Sources/RxSubscriptionIOS/Client.swift @@ -12,6 +12,13 @@ public struct UserIdentity: Hashable, Sendable { } } +/// Hands the client a currently valid rxlab access token for the signed-in user. +/// +/// Called before every request made with a publishable key, and called again +/// with `forceRefresh: true` if the server rejects the token, so the app's own +/// refresh machinery stays the single owner of the session. +public typealias UserTokenProvider = @Sendable (_ forceRefresh: Bool) async throws -> String + public enum ClientError: Error, LocalizedError { case invalidConfiguration(String) case invalidURL @@ -19,6 +26,7 @@ public enum ClientError: Error, LocalizedError { case server(statusCode: Int, payload: APIErrorPayload?, responseBody: String?) case storeProductNotFound(String) case unverifiedStoreTransaction + case userTokenUnavailable(any Error) public var errorDescription: String? { switch self { @@ -29,6 +37,8 @@ public enum ClientError: Error, LocalizedError { return payload?.errorDescription ?? payload?.error ?? body ?? "The subscription request failed." case .storeProductNotFound(let id): return "App Store product not found: \(id)" case .unverifiedStoreTransaction: return "StoreKit could not verify the transaction on this device." + case .userTokenUnavailable(let error): + return "Could not read the signed-in user's session: \(error.localizedDescription)" } } } @@ -37,6 +47,18 @@ public enum ClientError: Error, LocalizedError { /// /// Create one client for the signed-in user and pass it directly to the package's /// SwiftUI views. The backend API key determines sandbox versus production. +/// +/// Which initializer you use depends on the kind of key you hold: +/// +/// - ``init(serverURL:publishableKey:rxlabUserID:email:displayName:userToken:session:)`` +/// is the one an app wants. A publishable key is safe to ship in a binary +/// because it does nothing on its own: every request also carries the +/// signed-in user's access token, and the server acts only for whoever that +/// token identifies. +/// - ``init(serverURL:apiKey:rxlabUserID:email:displayName:session:)`` takes a +/// secret key, which reaches every endpoint and names its own user. That +/// belongs on a server. Shipping one inside an app lets anyone who extracts +/// it grant themselves anything. @MainActor public final class Client { public let serverURL: URL @@ -46,7 +68,12 @@ public final class Client { private let session: URLSession private let encoder: JSONEncoder private let decoder: JSONDecoder + private let userTokenProvider: UserTokenProvider? + /// Creates a client backed by a secret, server-to-server API key. + /// + /// - Warning: A secret key must not ship inside an application binary. Use + /// a publishable key for that. public init( serverURL: URL, apiKey: String, @@ -65,6 +92,40 @@ public final class Client { self.session = session self.encoder = JSONEncoder() self.decoder = Self.makeDecoder() + self.userTokenProvider = nil + } + + /// Creates a client backed by a publishable key and the signed-in user's session. + /// + /// `rxlabUserID` is still sent so requests keep their existing shape, but + /// the server no longer takes the app's word for it: the user comes from + /// the access token, and a request naming somebody else is refused. Pass + /// the same id the token was issued for. + /// + /// - Parameter userToken: Returns a currently valid access token. It is + /// called again with `forceRefresh: true` if the server rejects the + /// token, so a session that expired mid-screen recovers without the user + /// noticing. + public init( + serverURL: URL, + publishableKey: String, + rxlabUserID: String, + email: String? = nil, + displayName: String? = nil, + userToken: @escaping UserTokenProvider, + session: URLSession = .shared + ) { + self.serverURL = serverURL + self.apiKey = publishableKey + self.user = UserIdentity( + rxlabUserID: rxlabUserID, + email: email, + displayName: displayName + ) + self.session = session + self.encoder = JSONEncoder() + self.decoder = Self.makeDecoder() + self.userTokenProvider = userToken } // MARK: Storefront and entitlements @@ -479,10 +540,18 @@ public final class Client { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw ClientError.invalidResponse + var (data, http) = try await send(request, refreshingUserToken: false) + + // A publishable key's request is only as good as the token it carries. + // An access token that expired while a screen sat open is the ordinary + // case, not an error to surface, so ask for a fresh one and try once + // more. Anything still failing after that is the caller's to handle. + if http.statusCode == 401, + !acceptedStatusCodes.contains(401), + userTokenProvider != nil { + (data, http) = try await send(request, refreshingUserToken: true) } + guard acceptedStatusCodes.contains(http.statusCode) else { throw ClientError.server( statusCode: http.statusCode, @@ -490,11 +559,30 @@ public final class Client { responseBody: String(data: data, encoding: .utf8) ) } - do { - return try decoder.decode(Response.self, from: data) - } catch { - throw error + return try decoder.decode(Response.self, from: data) + } + + /// Attaches the user token, if this client carries one, and dispatches. + private func send( + _ request: URLRequest, + refreshingUserToken: Bool + ) async throws -> (Data, HTTPURLResponse) { + var request = request + if let userTokenProvider { + let token: String + do { + token = try await userTokenProvider(refreshingUserToken) + } catch { + throw ClientError.userTokenUnavailable(error) + } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ClientError.invalidResponse } + return (data, http) } private func url(for path: String) -> URL { diff --git a/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift b/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift index b12dd02..586b046 100644 --- a/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift +++ b/Sources/RxSubscriptionIOS/RxSubscriptionIOS.swift @@ -1,5 +1,5 @@ /// RxSubscriptionIOS provides a typed backend client, StoreKit 2 fulfillment, /// and ready-to-use SwiftUI subscription, top-up, usage, and balance views. public enum RxSubscriptionIOS { - public static let version = "0.1.0" + public static let version = "0.2.0" } diff --git a/Sources/RxSubscriptionIOS/StoreKitSupport.swift b/Sources/RxSubscriptionIOS/StoreKitSupport.swift index 0e35942..bd61634 100644 --- a/Sources/RxSubscriptionIOS/StoreKitSupport.swift +++ b/Sources/RxSubscriptionIOS/StoreKitSupport.swift @@ -42,6 +42,45 @@ public extension Client { } } + /// Forwards StoreKit transactions that arrive outside a purchase call. + /// + /// Renewals, Ask-to-Buy approvals, purchases made on another device, and + /// anything interrupted mid-flight all land on `Transaction.updates` rather + /// than as the result of ``purchaseApple(productID:quantity:)``. Without an + /// observer they are never finished, so StoreKit re-delivers them on every + /// launch and the app's own view of the entitlement lags. + /// + /// The server is still the authority — App Store Server Notifications reach + /// it whether or not the app is running — so a submission that fails here + /// is logged past rather than retried: the transaction stays unfinished and + /// StoreKit will offer it again. + /// + /// Start this once, early, and hold the returned task for the lifetime of + /// the session; cancelling it stops the observation. + /// + /// - Parameter onFulfillment: Called on the main actor after each accepted + /// transaction, so a store can refresh its cached balance. + func observeTransactionUpdates( + onFulfillment: (@MainActor (AppleFulfillment) -> Void)? = nil + ) -> Task { + Task { [weak self] in + for await verification in Transaction.updates { + guard let self else { return } + guard case .verified(let transaction) = verification else { continue } + do { + let fulfillment = try await self.submitAppleTransaction( + verification.jwsRepresentation + ) + await transaction.finish() + onFulfillment?(fulfillment) + } catch { + // Deliberately left unfinished — see above. + continue + } + } + } + } + /// Presents Apple's restore sheet, reconciles every current entitlement, and finishes it. @discardableResult func restoreApplePurchases() async throws -> [AppleFulfillment] { diff --git a/Tests/RxSubscriptionIOSTests/PublishableKeyTests.swift b/Tests/RxSubscriptionIOSTests/PublishableKeyTests.swift new file mode 100644 index 0000000..8b49f4e --- /dev/null +++ b/Tests/RxSubscriptionIOSTests/PublishableKeyTests.swift @@ -0,0 +1,192 @@ +import Foundation +import XCTest +@testable import RxSubscriptionIOS + +/// Records what each attempt saw, so a retry can be told apart from a first try. +private actor RequestLog { + private(set) var authorizations: [String?] = [] + + func record(_ value: String?) { authorizations.append(value) } +} + +@MainActor +final class PublishableKeyTests: XCTestCase { + private var log: RequestLog! + + override func setUp() { + super.setUp() + log = RequestLog() + } + + override func tearDown() { + URLProtocolStub.handler = nil + log = nil + super.tearDown() + } + + private func makeClient( + userToken: @escaping UserTokenProvider + ) -> Client { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return Client( + serverURL: URL(string: "https://subscriptions.example.test")!, + publishableKey: "rxs_pk_sandbox_test", + rxlabUserID: "user-42", + email: "reader@example.test", + userToken: userToken, + session: URLSession(configuration: configuration) + ) + } + + func testSendsPublishableKeyAndUserTokenTogether() async throws { + let client = makeClient { _ in "access-token-1" } + URLProtocolStub.handler = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "rxs_pk_sandbox_test") + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer access-token-1" + ) + return (200, Self.balancesJSON) + } + + let balances = try await client.balances() + XCTAssertEqual(balances.first?.unit, "credits") + } + + func testRetriesOnceWithAFreshTokenAfter401() async throws { + let log = log! + let client = makeClient { forceRefresh in + forceRefresh ? "access-token-2" : "access-token-1" + } + + URLProtocolStub.handler = { request in + let authorization = request.value(forHTTPHeaderField: "Authorization") + Task { await log.record(authorization) } + if authorization == "Bearer access-token-1" { + return (401, #"{"error":"invalid_user_token"}"#) + } + return (200, Self.balancesJSON) + } + + let balances = try await client.balances() + XCTAssertEqual(balances.first?.unit, "credits") + + // Give the detached recording tasks a turn before reading the log. + try await Task.sleep(nanoseconds: 50_000_000) + let seen = await log.authorizations + XCTAssertEqual(seen, ["Bearer access-token-1", "Bearer access-token-2"]) + } + + func testGivesUpAfterOneRetryRatherThanLoopingOnAStaleSession() async throws { + let log = log! + let client = makeClient { _ in "always-stale" } + + URLProtocolStub.handler = { request in + Task { await log.record(request.value(forHTTPHeaderField: "Authorization")) } + return (401, #"{"error":"invalid_user_token"}"#) + } + + do { + _ = try await client.balances() + XCTFail("expected the second 401 to surface") + } catch let error as ClientError { + guard case .server(let status, let payload, _) = error else { + return XCTFail("expected a server error, got \(error)") + } + XCTAssertEqual(status, 401) + XCTAssertEqual(payload?.error, "invalid_user_token") + } + + try await Task.sleep(nanoseconds: 50_000_000) + let seen = await log.authorizations + XCTAssertEqual(seen.count, 2, "one attempt plus exactly one refreshed retry") + } + + func testReportsAFailedTokenLookupAsSuchRatherThanAsANetworkError() async throws { + struct SignedOut: Error {} + let client = makeClient { _ in throw SignedOut() } + URLProtocolStub.handler = { _ in + XCTFail("no request should be sent without a token") + return (200, Self.balancesJSON) + } + + do { + _ = try await client.balances() + XCTFail("expected the token failure to surface") + } catch let error as ClientError { + guard case .userTokenUnavailable(let underlying) = error else { + return XCTFail("expected .userTokenUnavailable, got \(error)") + } + XCTAssertTrue(underlying is SignedOut) + } + } + + func testSecretKeyClientStillSendsNoAuthorizationHeader() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let client = Client( + serverURL: URL(string: "https://subscriptions.example.test")!, + apiKey: "rxs_sandbox_test", + rxlabUserID: "user-42", + session: URLSession(configuration: configuration) + ) + URLProtocolStub.handler = { request in + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "rxs_sandbox_test") + return (200, Self.balancesJSON) + } + + _ = try await client.balances() + } + + func testA401IsNotRetriedForASecretKeyClient() async throws { + let log = log! + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + let client = Client( + serverURL: URL(string: "https://subscriptions.example.test")!, + apiKey: "rxs_sandbox_test", + rxlabUserID: "user-42", + session: URLSession(configuration: configuration) + ) + URLProtocolStub.handler = { request in + Task { await log.record(request.value(forHTTPHeaderField: "X-Api-Key")) } + return (401, #"{"error":"invalid_api_key"}"#) + } + + do { + _ = try await client.balances() + XCTFail("expected the 401 to surface") + } catch let error as ClientError { + guard case .server(let status, _, _) = error else { + return XCTFail("expected a server error, got \(error)") + } + XCTAssertEqual(status, 401) + } + + try await Task.sleep(nanoseconds: 50_000_000) + let seen = await log.authorizations + XCTAssertEqual(seen.count, 1, "a bad secret key is not something a refresh can fix") + } + + /// `recordUsage` accepts 402 as a decodable outcome. That must not be + /// confused with the 401 path, which is the only status the client retries. + func testAcceptedStatusCodesAreLeftAlone() async throws { + let client = makeClient { _ in "access-token-1" } + URLProtocolStub.handler = { _ in (402, Self.usageDeniedJSON) } + + let result = try await client.recordUsage(item: "generation", idempotencyKey: "k") + XCTAssertFalse(result.allowed) + XCTAssertEqual(result.reason, "limit_exceeded") + } + + private static let balancesJSON = """ + {"balances":[{"unit":"credits","name":"Credits","symbol":null,"precision":0,"amount":25,"available":25}]} + """ + + private static let usageDeniedJSON = """ + {"allowed":false,"reason":"limit_exceeded","used":10,"limit":10,"remaining":0,\ + "chargedUnits":0,"periodEnd":null,"duplicate":false} + """ +}