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
3 changes: 3 additions & 0 deletions Sources/AmoreLicensing/Models/Customer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ public struct Customer: Hashable, Codable, Sendable {
/// The email address the license was issued to, or `nil` if the issuing
/// token carries a customer without an email.
public var email: String?
/// The customer's name, or `nil` if the issuing token carries a customer
/// without a name.
public var name: String?
}
9 changes: 8 additions & 1 deletion Sources/AmoreLicensing/Models/License.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ public struct License: Identifiable, Hashable, Codable, Sendable {
/// The customer this license was issued to, or `nil` if the license has no
/// associated customer. Use `customer?.email` to show "Licensed to <email>".
public var customer: Customer?
/// When the license was issued, or `nil` if the token predates this claim.
/// For purchased licenses this is the purchase time. Useful for showing
/// license details in settings, e.g. to help customers find their email
/// receipt. The server carries this claim on every token it mints for the
/// license, so the value is stable across validations.
public var issuedAt: Date?

/// The product name this license is for.
@available(*, deprecated, renamed: "product.name", message: "Use `product.name` instead.")
Expand All @@ -31,7 +37,8 @@ extension License {
expiresAt: payload.exp,
entitlements: payload.entitlements,
subscriptionState: payload.subscriptionState,
customer: payload.customer
customer: payload.customer,
issuedAt: payload.issuedAt
)
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/AmoreLicensing/Payload/LicensePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct LicensePayload: Codable, Sendable {
var entitlements: Set<License.Entitlement> = []
var subscriptionState: SubscriptionState?
var customer: Customer?
var issuedAt: Date?

enum CodingKeys: String, CodingKey {
case exp
Expand All @@ -21,5 +22,6 @@ struct LicensePayload: Codable, Sendable {
case entitlements
case subscriptionState = "subscription_state"
case customer
case issuedAt = "issued_at"
}
}
84 changes: 83 additions & 1 deletion Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,34 @@ struct LicensePayloadDecodeTests {
"nonce": "n-1",
"product": { "name": "Pro", "identifier": "pro" },
"entitlements": [],
"customer": { "email": "buyer@example.com" }
"customer": { "email": "buyer@example.com", "name": "Amy Worrall" }
}
"""
let payload = try decodePayload(json)
let license = License(from: payload)
#expect(license.customer?.email == "buyer@example.com")
#expect(license.customer?.name == "Amy Worrall")
}

// A name-only customer (license issued without an email) must decode with
// a nil email and carry the name.
@Test func customerDecodesWithNameOnly() 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": [],
"customer": { "name": "Amy Worrall" }
}
"""
let payload = try decodePayload(json)
let license = License(from: payload)
#expect(license.customer?.name == "Amy Worrall")
#expect(license.customer?.email == nil)
}

// An older token (or any license issued without an email) has no `customer`
Expand All @@ -126,6 +148,66 @@ struct LicensePayloadDecodeTests {
#expect(license.customer == nil)
}

// The full claim set a current server mints: customer name and email plus
// the issue date, all on one token.
@Test func decodesFullCurrentTokenShape() 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": [],
"customer": { "email": "buyer@example.com", "name": "Amy Worrall" },
"issued_at": 1778000000
}
"""
let payload = try decodePayload(json)
let license = License(from: payload)
#expect(license.customer?.email == "buyer@example.com")
#expect(license.customer?.name == "Amy Worrall")
#expect(license.issuedAt == Date(timeIntervalSince1970: 1_778_000_000))
}

@Test func licenseFromPayloadCarriesIssueDate() 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": [],
"issued_at": 1778000000
}
"""
let payload = try decodePayload(json)
let license = License(from: payload)
#expect(license.issuedAt == Date(timeIntervalSince1970: 1_778_000_000))
}

// An older token has no `issued_at` claim; the optional must decode to
// nil, not error.
@Test func licenseHasNilIssueDateWhenClaimAbsent() 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.issuedAt == 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
Loading