Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Automatic profile regeneration #22

Merged
merged 15 commits into from
Jun 4, 2024
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ OPTIONS:
OpenSSL documentation for this flag
(https://www.openssl.org/docs/manmaster/man1/openssl-req.html):

Sets
subject name for new request or supersedes the
Sets subject name for new request or supersedes the
subject name when processing a certificate request.

The arg must be formatted as
Expand All @@ -157,6 +156,9 @@ Sets
specify the members of the set. Example:

/DC=org/DC=OpenSSL/DC=users/UID=123456+CN=JohnDoe
--auto-regenerate
Defines if the profile should be regenerated in case
it already exists (optional)
-h, --help Show help information.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
)
case unableToCreateCSR(output: ShellOutput)
case unableToImportIntermediaryAppleCertificate(certificate: String, output: ShellOutput)
case profileNameMissing

var description: String {
switch self {
Expand Down Expand Up @@ -101,6 +102,8 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
- Output: \(output.outputString)
- Error: \(output.errorString)
"""
case .profileNameMissing:
return "--auto-regenerate flag requires that you include a profile name using the argument --profile-name"
}
}
}
Expand All @@ -122,6 +125,7 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
case intermediaryAppleCertificates = "intermediaryAppleCertificates"
case certificateSigningRequestSubject = "certificateSigningRequestSubject"
case profileName = "profileName"
case autoRegenerate = "autoRegenerate"
}

@Option(help: "The key identifier of the private key (https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests)")
Expand Down Expand Up @@ -182,6 +186,9 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
""")
internal var certificateSigningRequestSubject: String

@Flag(help: "Defines if the profile should be regenerated in case it already exists (optional)")
internal var autoRegenerate = false

private let files: Files
private let log: Log
private let shell: Shell
Expand Down Expand Up @@ -228,7 +235,8 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
certificateSigningRequestSubject: String,
bundleIdentifierName: String?,
platform: String,
profileName: String?
profileName: String?,
autoRegenerate: Bool
) {
self.files = files
self.log = log
Expand All @@ -252,6 +260,7 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
self.bundleIdentifierName = bundleIdentifierName
self.platform = platform
self.profileName = profileName
self.autoRegenerate = autoRegenerate
}

internal init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -286,18 +295,36 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
certificateSigningRequestSubject: try container.decode(String.self, forKey: .certificateSigningRequestSubject),
bundleIdentifierName: try container.decodeIfPresent(String.self, forKey: .bundleIdentifierName),
platform: try container.decode(String.self, forKey: .platform),
profileName: try container.decodeIfPresent(String.self, forKey: .profileName)
profileName: try container.decodeIfPresent(String.self, forKey: .profileName),
autoRegenerate: try container.decode(Bool.self, forKey: .autoRegenerate)
)
}

internal func run() throws {
let privateKey: Path = .init(privateKeyPath)
let csr: Path = try createCSR(privateKey: privateKey)
let jsonWebToken: String = try jsonWebTokenService.createToken(
keyIdentifier: keyIdentifier,
issuerID: issuerID,
secretKey: try files.read(Path(itunesConnectKeyPath))
)
let deviceIDs: Set<String> = try iTunesConnectService.fetchITCDeviceIDs(jsonWebToken: jsonWebToken)
guard let profileName, let profile = try? fetchProvisioningProfile(jsonWebToken: jsonWebToken, name: profileName)
else {
try createProvisioningProfile(jsonWebToken: jsonWebToken, deviceIDs: deviceIDs)
return
}
guard autoRegenerate, shouldRegenerate(profile: profile, with: deviceIDs)
else {
try save(profile: profile)
log.append("The profile already exists")
return
}
try deleteProvisioningProfile(jsonWebToken: jsonWebToken, id: profile.id)
try createProvisioningProfile(jsonWebToken: jsonWebToken, deviceIDs: deviceIDs)
}

private func createProvisioningProfile(jsonWebToken: String, deviceIDs: Set<String>) throws {
let privateKey: Path = .init(privateKeyPath)
let csr: Path = try createCSR(privateKey: privateKey)
let tuple: (cer: Path, certificateId: String) = try fetchOrCreateCertificate(jsonWebToken: jsonWebToken, csr: csr)
let cer: Path = tuple.cer
let certificateId: String = tuple.certificateId
Expand All @@ -311,7 +338,6 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
try importP12IdentityIntoKeychain(p12Identity: p12Identity, identityPassword: identityPassword)
try importIntermediaryAppleCertificates()
try updateKeychainPartitionList()
let deviceIDs: Set<String> = try iTunesConnectService.fetchITCDeviceIDs(jsonWebToken: jsonWebToken)
let profileResponse: CreateProfileResponse = try iTunesConnectService.createProfile(
jsonWebToken: jsonWebToken,
bundleId: try iTunesConnectService.determineBundleIdITCId(
Expand All @@ -325,11 +351,7 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
profileType: profileType,
profileName: profileName
)
guard let profileData: Data = .init(base64Encoded: profileResponse.data.attributes.profileContent)
else {
throw Error.unableToBase64DecodeProfile(name: profileResponse.data.attributes.name)
}
try files.write(profileData, to: .init(outputPath))
try save(profile: profileResponse.data)
log.append(profileResponse.data.id)
}

Expand Down Expand Up @@ -496,4 +518,44 @@ internal struct CreateProvisioningProfileCommand: ParsableCommand {
)
}
}

private func fetchProvisioningProfile(jsonWebToken: String, name: String) throws -> ProfileResponseData? {
try iTunesConnectService.fetchProvisioningProfile(
jsonWebToken: jsonWebToken,
name: name
).first(where: { $0.attributes.name == name })
}

private func deleteProvisioningProfile(jsonWebToken: String, id: String) throws {
try iTunesConnectService.deleteProvisioningProfile(
jsonWebToken: jsonWebToken,
id: id
)
log.append("Deleted profile with id: \(id)")
}

private func save(profile: ProfileResponseData) throws {
guard let profileData: Data = .init(base64Encoded: profile.attributes.profileContent)
else {
throw Error.unableToBase64DecodeProfile(name: profile.attributes.name)
}
try files.write(profileData, to: .init(outputPath))
}

private func shouldRegenerate(profile: ProfileResponseData, with deviceIDs: Set<String>) -> Bool {
guard ProfileType(rawValue: profileType).usesDevices else { return false }
let profileDevices = Set(profile.relationships.devices.data.map { $0.id })
let shouldRegenerate = deviceIDs != profileDevices
if shouldRegenerate {
let missingDevices = deviceIDs.subtracting(profileDevices)
log.append("The profile will be regenerated because it is missing the device(s): \(missingDevices.joined(separator: ", "))")
}
return shouldRegenerate
}

mutating internal func validate() throws {
if autoRegenerate, profileName == nil {
throw Error.profileNameMissing
}
}
}
17 changes: 1 addition & 16 deletions Sources/SignHereLibrary/Models/CreateProfileResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,5 @@
import Foundation

internal struct CreateProfileResponse: Codable {
struct CreateProfileResponseData: Codable {
struct Attributes: Codable {
var profileContent: String
var uuid: String
var name: String
var platform: String
var createdDate: Date
var profileState: String
var profileType: String
var expirationDate: Date
}
var id: String
var type: String
var attributes: Attributes
}
var data: CreateProfileResponseData
var data: ProfileResponseData
}
12 changes: 12 additions & 0 deletions Sources/SignHereLibrary/Models/GetProfilesResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// GetProfilesResponse.swift
// Models
//
// Created by Omar Zuniga on 29/05/24.
//

import Foundation

internal struct GetProfilesResponse: Codable {
var data: [ProfileResponseData]
}
36 changes: 36 additions & 0 deletions Sources/SignHereLibrary/Models/ProfileResponseData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// CreateProfileResponse.swift
// Models
//
// Created by Omar Zuniga on 29/05/24.
//

import Foundation

struct ProfileResponseData: Codable {
struct Attributes: Codable {
var profileContent: String
var uuid: String
var name: String
var platform: String
var createdDate: Date
var profileState: String
var profileType: String
var expirationDate: Date
}
struct Relationships: Codable {
struct Devices: Codable {
struct Data: Codable {
var id: String
var type: String
}

var data: [Data]
}
var devices: Devices
}
var id: String
var type: String
var attributes: Attributes
var relationships: Relationships
}
35 changes: 35 additions & 0 deletions Sources/SignHereLibrary/Models/ProfileType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// ProfileType.swift
// Models
//
// Created by Omar Zuniga on 29/05/24.
//

import Foundation

enum ProfileType {
case development
case adHoc
case appStore
case inHouse
case direct
case unknown

init(rawValue: String) {
switch rawValue {
case let str where str.hasSuffix("_APP_DEVELOPMENT"): self = .development
case let str where str.hasSuffix("_APP_ADHOC"): self = .adHoc
case let str where str.hasSuffix("_APP_STORE"): self = .appStore
case let str where str.hasSuffix("_APP_INHOUSE"): self = .inHouse
case let str where str.hasSuffix("_APP_DIRECT"): self = .direct
default: self = .unknown
}
}

var usesDevices: Bool {
switch self {
case .appStore: return false
default: return true
}
}
}
39 changes: 38 additions & 1 deletion Sources/SignHereLibrary/Services/iTunesConnectService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ internal protocol iTunesConnectService {
jsonWebToken: String,
id: String
) throws
func fetchProvisioningProfile(
jsonWebToken: String,
name: String
) throws -> [ProfileResponseData]
}

internal class iTunesConnectServiceImp: iTunesConnectService {
Expand Down Expand Up @@ -368,7 +372,7 @@ internal class iTunesConnectServiceImp: iTunesConnectService {
let profileName = profileName ?? "\(certificateId)_\(profileType)_\(clock.now().timeIntervalSince1970)"
var devices: CreateProfileRequest.CreateProfileRequestData.Relationships.Devices? = nil
// ME: App Store profiles cannot use UDIDs
if !["IOS_APP_STORE", "MAC_APP_STORE", "TVOS_APP_STORE", "MAC_CATALYST_APP_STORE"].contains(profileType) {
if ProfileType(rawValue: profileType).usesDevices {
devices = .init(
data: deviceIDs.sorted().map {
CreateProfileRequest.CreateProfileRequestData.Relationships.Devices.DevicesData(
Expand Down Expand Up @@ -440,6 +444,39 @@ internal class iTunesConnectServiceImp: iTunesConnectService {
}
}

func fetchProvisioningProfile(
jsonWebToken: String,
name: String
) throws -> [ProfileResponseData] {
var urlComponents: URLComponents = .init()
urlComponents.scheme = Constants.httpsScheme
urlComponents.host = Constants.itcHost
urlComponents.path = "/v1/profiles"
urlComponents.queryItems = [
.init(name: "filter[name]", value: name),
.init(name: "include", value: "devices")
]
guard let url: URL = urlComponents.url
else {
throw Error.unableToCreateURL(urlComponents: urlComponents)
}
var request: URLRequest = .init(url: url)
request.setValue("Bearer \(jsonWebToken)", forHTTPHeaderField: "Authorization")
request.setValue(Constants.applicationJSONHeaderValue, forHTTPHeaderField: "Accept")
request.setValue(Constants.applicationJSONHeaderValue, forHTTPHeaderField: Constants.contentTypeHeaderName)
request.httpMethod = "GET"
let jsonDecoder: JSONDecoder = createITCApiJSONDecoder()
let data: Data = try network.execute(request: request)
do {
return try jsonDecoder.decode(
GetProfilesResponse.self,
from: data
).data
} catch let decodingError as DecodingError {
throw Error.unableToDecodeResponse(responseData: data, decodingError: decodingError)
}
}

private func createITCApiJSONDecoder() -> JSONDecoder {
let jsonDecoder: JSONDecoder = .init()
let dateFormatter: DateFormatter = .init()
Expand Down
Loading
Loading