From 42b00ea1038c79e649f2e54a61e45db8a57247af Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Sat, 3 Oct 2026 09:59:55 +0200 Subject: [PATCH 1/2] feat(licensing): stop grace at the license end date --- Sources/AmoreLicensing/AmoreLicensing.swift | 8 +- .../Architecture & Security.md | 5 +- .../Documentation.docc/Getting Started.md | 2 +- .../AmoreLicensing/Models/GracePeriod.swift | 4 + Sources/AmoreLicensing/Models/License.swift | 5 +- .../Models/SubscriptionState.swift | 7 + .../Models/ValidationStatus.swift | 3 +- .../Payload/LicensePayload.swift | 3 + .../AmoreClientTests.swift | 17 ++- .../GracePeriodTests.swift | 121 ++++++++++++++++++ .../LicensePayloadDecodeTests.swift | 37 ++++++ readme.md | 2 +- 12 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 Tests/AmoreLicensingTests/GracePeriodTests.swift diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index 048ddef..a066b57 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -239,10 +239,16 @@ public final class AmoreLicensing: Licensing { } /// The license a still-within-grace expired payload represents, or `nil` once - /// the grace period has elapsed. + /// the grace period has elapsed. Grace covers a server the app cannot reach, + /// not a license that has ended, so a license past its own end date gets none + /// unless it is a subscription that renews. private func graceLicense(for payload: LicensePayload) -> License? { let graceEnd = payload.exp.addingTimeInterval(configuration.gracePeriod.timeInterval) guard graceEnd >= .now else { return nil } + if let licenseEnd = payload.expiresAt, licenseEnd <= .now, + payload.subscriptionState?.renews != true { + return nil + } return License(from: payload) } diff --git a/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md b/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md index c89f04b..509882b 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md +++ b/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md @@ -87,7 +87,8 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a > It carries a paragraph you can paste into your own privacy policy. ### Grace Period -- App continues working N days after last successful validation +- App continues working N days after its token expires - Handles temporary network outages or server downtime +- Does not apply to a license past its own end date, unless it is a subscription that renews - Configurable per-app (default: 7 days). ``LicensingConfiguration/gracePeriod`` -- Stored: last validation timestamp from JWT +- Needs no extra state: the SDK computes it from the stored token's expiry diff --git a/Sources/AmoreLicensing/Documentation.docc/Getting Started.md b/Sources/AmoreLicensing/Documentation.docc/Getting Started.md index fdde756..ca53a67 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Getting Started.md +++ b/Sources/AmoreLicensing/Documentation.docc/Getting Started.md @@ -58,7 +58,7 @@ case .valid(let license): case .invalid: print("License is invalid") case .gracePeriod(let license): - print("Grace period until \(endDate)") + print("License not verified recently. \(license.product.name) works during the grace period.") case .unknown: print("License status unknown") } diff --git a/Sources/AmoreLicensing/Models/GracePeriod.swift b/Sources/AmoreLicensing/Models/GracePeriod.swift index c0d708f..e6a6c7f 100644 --- a/Sources/AmoreLicensing/Models/GracePeriod.swift +++ b/Sources/AmoreLicensing/Models/GracePeriod.swift @@ -1,6 +1,10 @@ import Foundation /// The duration after token expiry during which the license remains usable. +/// +/// Grace covers a license server the app cannot reach. It does not apply to a +/// license that has passed its own end date, unless it is a subscription that +/// renews. public enum GracePeriod: Sendable { /// A grace period measured in days. case days(Int) diff --git a/Sources/AmoreLicensing/Models/License.swift b/Sources/AmoreLicensing/Models/License.swift index bad0b62..047155f 100644 --- a/Sources/AmoreLicensing/Models/License.swift +++ b/Sources/AmoreLicensing/Models/License.swift @@ -6,7 +6,8 @@ public struct License: Identifiable, Hashable, Codable, Sendable { public var id: UUID /// The product this license is for. public var product: Product - /// When the license expires, or `nil` if it never expires. + /// When the license expires, or `nil` if it never expires or the token + /// predates this claim. A subscription's date moves with each renewal. public var expiresAt: Date? /// The set of entitlement keys granted by this license. public var entitlements: Set @@ -34,7 +35,7 @@ extension License { self = License( id: payload.licenseId, product: payload.product, - expiresAt: payload.exp, + expiresAt: payload.expiresAt, entitlements: payload.entitlements, subscriptionState: payload.subscriptionState, customer: payload.customer, diff --git a/Sources/AmoreLicensing/Models/SubscriptionState.swift b/Sources/AmoreLicensing/Models/SubscriptionState.swift index 57ed2f7..64985cf 100644 --- a/Sources/AmoreLicensing/Models/SubscriptionState.swift +++ b/Sources/AmoreLicensing/Models/SubscriptionState.swift @@ -25,6 +25,13 @@ public enum SubscriptionState: Sendable, Hashable { /// Terminal: canceled, unpaid, expired, or any unrecoverable state. case lapsed + /// Whether the subscription renews, so each renewal moves its end date. + var renews: Bool { + switch self { + case .renewing, .trialing(_, canceledAt: nil): true + default: false + } + } } extension SubscriptionState: Codable { diff --git a/Sources/AmoreLicensing/Models/ValidationStatus.swift b/Sources/AmoreLicensing/Models/ValidationStatus.swift index a1eebfd..10e13fa 100644 --- a/Sources/AmoreLicensing/Models/ValidationStatus.swift +++ b/Sources/AmoreLicensing/Models/ValidationStatus.swift @@ -2,7 +2,8 @@ import Foundation /// The result of a license validation check. public enum ValidationStatus: Sendable, Equatable { - /// The license has expired but is within the configured grace period. + /// The stored token has expired and no refresh has succeeded yet. The + /// license works until ``LicensingConfiguration/gracePeriod`` elapses. case gracePeriod(License) /// The license is invalid or revoked. case invalid diff --git a/Sources/AmoreLicensing/Payload/LicensePayload.swift b/Sources/AmoreLicensing/Payload/LicensePayload.swift index c7656b1..81e12e4 100644 --- a/Sources/AmoreLicensing/Payload/LicensePayload.swift +++ b/Sources/AmoreLicensing/Payload/LicensePayload.swift @@ -11,6 +11,8 @@ struct LicensePayload: Codable, Sendable { var subscriptionState: SubscriptionState? var customer: Customer? var issuedAt: Date? + /// The license's own end date. `exp` only bounds this token. + var expiresAt: Date? enum CodingKeys: String, CodingKey { case exp @@ -23,5 +25,6 @@ struct LicensePayload: Codable, Sendable { case subscriptionState = "subscription_state" case customer case issuedAt = "issued_at" + case expiresAt = "expires_at" } } diff --git a/Tests/AmoreLicensingTests/AmoreClientTests.swift b/Tests/AmoreLicensingTests/AmoreClientTests.swift index 0acb141..92cfe57 100644 --- a/Tests/AmoreLicensingTests/AmoreClientTests.swift +++ b/Tests/AmoreLicensingTests/AmoreClientTests.swift @@ -208,9 +208,10 @@ import Testing let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() let expDate = Date().addingTimeInterval(-2 * 24 * 3600) // expired 2 days ago + let licenseId = UUID() let expired = try signToken( privateKey: privateKey, hardwareId: hardwareId, nonce: "old", - exp: expDate + exp: expDate, licenseId: licenseId ) try store.store(expired) @@ -224,9 +225,9 @@ import Testing Issue.record("Expected gracePeriod, got \(result)") return } - #expect(abs(license.expiresAt!.timeIntervalSince(expDate)) < 1) + #expect(license.id == licenseId) } - + @Test func validateExpiredTokenValidates() async throws { let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() @@ -354,11 +355,13 @@ import Testing let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() let expDate = Date().addingTimeInterval(-2 * 24 * 3600) // expired 2 days ago + let licenseId = UUID() let token = try signToken( - privateKey: privateKey, hardwareId: hardwareId, nonce: "stored", exp: expDate + privateKey: privateKey, hardwareId: hardwareId, nonce: "stored", exp: expDate, + licenseId: licenseId ) try store.store(token) - + let client = try AmoreLicensing( publicKey: publicKey.rawRepresentation.base64URLEncodedString(), bundleIdentifier: bundleId, @@ -366,12 +369,12 @@ import Testing deviceIdentity: MockDeviceIdentity(identifier: hardwareId), tokenStore: store ) - + guard case .gracePeriod(let license) = client.status else { Issue.record("Expected gracePeriod on first synchronous read, got \(client.status)") return } - #expect(abs(license.expiresAt!.timeIntervalSince(expDate)) < 1) + #expect(license.id == licenseId) } /// An expired token whose grace has already elapsed must stay `.unknown` at diff --git a/Tests/AmoreLicensingTests/GracePeriodTests.swift b/Tests/AmoreLicensingTests/GracePeriodTests.swift new file mode 100644 index 0000000..6e554b4 --- /dev/null +++ b/Tests/AmoreLicensingTests/GracePeriodTests.swift @@ -0,0 +1,121 @@ +import AmoreJWT +import Crypto +import Foundation +import Testing + +@testable import AmoreLicensing + +/// Grace covers a license server the app cannot reach, not a license that has +/// ended. Each scenario stores a token that expired two days ago, inside the +/// default seven-day grace period, and differs only in how the license ends. +@MainActor +@Suite struct GracePeriodTests { + struct Scenario: Sendable, CustomTestStringConvertible { + let testDescription: String + var subscriptionState: SubscriptionState? = nil + let expiresAt: Date + let keepsGrace: Bool + } + + private let hardwareId = "TEST-SERIAL-123" + private let bundleId = "com.test.amorekit" + + /// The server caps a token's expiry at the license end, so most scenarios + /// share this date for both. + private nonisolated static let tokenEnd = Date(timeIntervalSinceNow: -2 * 24 * 3600) + private nonisolated static let canceledAt = tokenEnd.addingTimeInterval(-10 * 24 * 3600) + + nonisolated static let scenarios = [ + Scenario( + testDescription: "license that has not ended", + expiresAt: .now.addingTimeInterval(30 * 24 * 3600), + keepsGrace: true + ), + Scenario(testDescription: "license that has ended", expiresAt: tokenEnd, keepsGrace: false), + Scenario( + testDescription: "renewing subscription", + subscriptionState: .renewing(renewsAt: tokenEnd), + expiresAt: tokenEnd, + keepsGrace: true + ), + Scenario( + testDescription: "trial that converts", + subscriptionState: .trialing(trialEndsAt: tokenEnd, canceledAt: nil), + expiresAt: tokenEnd, + keepsGrace: true + ), + Scenario( + testDescription: "canceled trial", + subscriptionState: .trialing(trialEndsAt: tokenEnd, canceledAt: canceledAt), + expiresAt: tokenEnd, + keepsGrace: false + ), + Scenario( + testDescription: "canceling subscription", + subscriptionState: .canceling(endsAt: tokenEnd, canceledAt: canceledAt), + expiresAt: tokenEnd, + keepsGrace: false + ), + ] + + @Test(arguments: scenarios) + func validateOffline(_ scenario: Scenario) async throws { + let (store, publicKey) = try storeToken(for: scenario) + let mock = MockLicenseClient() + mock.onValidate = { _, _ in throw URLError(.notConnectedToInternet) } + let client = AmoreLicensing( + publicKey: publicKey, + bundleIdentifier: bundleId, + tokenStore: store, + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), + licenseClient: mock + ) + + let result = try await client.validate() + + #expect(result.isGracePeriod == scenario.keepsGrace) + #expect(scenario.keepsGrace || result == .invalid) + } + + /// The launch initializer reads the stored token synchronously. Without + /// grace it stays `.unknown` and lets the server decide. + @Test(arguments: scenarios) + func launch(_ scenario: Scenario) throws { + let (store, publicKey) = try storeToken(for: scenario) + let unreachable = URL(string: "http://127.0.0.1:1")! + + let client = try AmoreLicensing( + publicKey: publicKey.rawRepresentation.base64URLEncodedString(), + bundleIdentifier: bundleId, + server: LicenseServer(activateURL: unreachable, deactivateURL: unreachable, validateURL: unreachable), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), + tokenStore: store + ) + + #expect(client.status.isGracePeriod == scenario.keepsGrace) + #expect(scenario.keepsGrace || client.status == .unknown) + } + + private func storeToken(for scenario: Scenario) throws -> (MockTokenStore, Curve25519.Signing.PublicKey) { + let privateKey = Curve25519.Signing.PrivateKey() + let payload = LicensePayload( + exp: Self.tokenEnd, + hardwareId: hardwareId, + iat: Date(), + licenseId: UUID(), + nonce: "stored", + product: .testSample, + subscriptionState: scenario.subscriptionState, + expiresAt: scenario.expiresAt + ) + let store = MockTokenStore() + try store.store(EdDSAJWT.sign(payload, using: privateKey)) + return (store, privateKey.publicKey) + } +} + +private extension ValidationStatus { + var isGracePeriod: Bool { + if case .gracePeriod = self { true } else { false } + } +} diff --git a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift index 44b37dc..87ae94c 100644 --- a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift +++ b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift @@ -208,6 +208,43 @@ struct LicensePayloadDecodeTests { #expect(license.issuedAt == nil) } + @Test func licenseFromPayloadCarriesExpiry() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [], + "expires_at": 1790000000 + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.expiresAt == Date(timeIntervalSince1970: 1_790_000_000)) + } + + // `exp` only bounds the token. A license that never expires has no + // `expires_at` claim and must not report the token's expiry as its own. + @Test func licenseHasNilExpiryWhenClaimAbsent() throws { + let json = """ + { + "exp": 1800000000, + "iat": 1779000000, + "hardware_id": "hw-1", + "license_id": "C7B53B0E-2C18-4F1D-8C9B-2B7B6A8B6A7E", + "nonce": "n-1", + "product": { "name": "Pro", "identifier": "pro" }, + "entitlements": [] + } + """ + let payload = try decodePayload(json) + let license = License(from: payload) + #expect(license.expiresAt == nil) + } + // A `customer` object may be present without an `email` (partial or // forward-compatible token). It must decode with a nil email rather than // throw and reject the entire license. diff --git a/readme.md b/readme.md index d563923..338b910 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,7 @@ struct ContentView: View { case .valid(let license): Text("Licensed to \(license.name)") case .gracePeriod(let license): - Text("License expired — grace period until \(license.expiresAt!)") + Text("License not verified recently. \(license.product.name) works during the grace period.") case .invalid: Text("License invalid") case .unknown: From d0610489f068efd5eac07d481f177310baf4ab99 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Sat, 3 Oct 2026 09:59:55 +0200 Subject: [PATCH 2/2] fix(checkout): show the trial end and lifetime licenses on the receipt --- .../UI/CheckoutCompletedView.swift | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift b/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift index 6220870..336c476 100644 --- a/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift +++ b/Sources/AmoreCheckout/UI/CheckoutCompletedView.swift @@ -78,16 +78,21 @@ struct CheckoutCompletedView: View { .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) } - // The single line that best describes how long the purchase lasts: - // the renewal date for a live subscription, otherwise the expiry. + // The single line that best describes how long the purchase lasts: the + // renewal date, the end of a trial that converts, "Lifetime" for a + // one-time purchase that never expires, otherwise the expiry. private var validity: (label: String, value: String)? { - if case .renewing(let renewsAt) = completion.license.subscriptionState { - return ("Renews", renewsAt.formatted(date: .abbreviated, time: .omitted)) + let license = completion.license + switch license.subscriptionState { + case .renewing(let renewsAt): return ("Renews", Self.day(renewsAt)) + case .trialing(let trialEndsAt, canceledAt: nil): return ("Trial ends", Self.day(trialEndsAt)) + case nil where license.expiresAt == nil: return ("License", "Lifetime") + default: return license.expiresAt.map { ("Valid until", Self.day($0)) } } - if let expiresAt = completion.license.expiresAt { - return ("Valid until", expiresAt.formatted(date: .abbreviated, time: .omitted)) - } - return nil + } + + private static func day(_ date: Date) -> String { + date.formatted(date: .abbreviated, time: .omitted) } private func detailRow(_ label: String, _ value: String) -> some View {