Skip to content

Commit

Permalink
Merge pull request #22 from omarzl/task/regenerate-profile
Browse files Browse the repository at this point in the history
[Feature] Automatic profile regeneration
  • Loading branch information
tinder-maxwellelliott authored Jun 4, 2024
2 parents 8f3bd6c + 94521f6 commit 008bbad
Show file tree
Hide file tree
Showing 23 changed files with 734 additions and 39 deletions.
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

0 comments on commit 008bbad

Please sign in to comment.