Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions Sources/AmoreCheckout/UI/CheckoutCompletedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion Sources/AmoreLicensing/AmoreLicensing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/AmoreLicensing/Models/GracePeriod.swift
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
5 changes: 3 additions & 2 deletions Sources/AmoreLicensing/Models/License.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entitlement>
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions Sources/AmoreLicensing/Models/SubscriptionState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion Sources/AmoreLicensing/Models/ValidationStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Sources/AmoreLicensing/Payload/LicensePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,5 +25,6 @@ struct LicensePayload: Codable, Sendable {
case subscriptionState = "subscription_state"
case customer
case issuedAt = "issued_at"
case expiresAt = "expires_at"
}
}
17 changes: 10 additions & 7 deletions Tests/AmoreLicensingTests/AmoreClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -354,24 +355,26 @@ 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,
server: unreachableServer(),
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
Expand Down
121 changes: 121 additions & 0 deletions Tests/AmoreLicensingTests/GracePeriodTests.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
37 changes: 37 additions & 0 deletions Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading