From 9fe635893e7fce0d8eaa19d8158d5def59b72c1a Mon Sep 17 00:00:00 2001 From: Kyle Hammond Date: Mon, 9 Oct 2023 11:21:53 -0500 Subject: [PATCH 1/6] #120 Networking code can work with OAuth client credentials. --- PPPC Utility.xcodeproj/project.pbxproj | 4 + PPPC UtilityTests/ModelTests/ModelTests.swift | 1 - .../NetworkAuthManagerTests.swift | 8 +- .../NetworkingTests/TokenTests.swift | 78 +++++++++++++++++- Source/Networking/JamfProAPIClient.swift | 28 +++++-- Source/Networking/NetworkAuthManager.swift | 45 +++++------ Source/Networking/Networking.swift | 28 ++++++- Source/Networking/Token.swift | 81 +++++++++++++++++++ 8 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 Source/Networking/Token.swift diff --git a/PPPC Utility.xcodeproj/project.pbxproj b/PPPC Utility.xcodeproj/project.pbxproj index f205514..2be6809 100644 --- a/PPPC Utility.xcodeproj/project.pbxproj +++ b/PPPC Utility.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ C03270BA28636330008B38E0 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03270B928636330008B38E0 /* SemanticVersion.swift */; }; C03270C028636397008B38E0 /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03270BE28636397008B38E0 /* Networking.swift */; }; C03270C128636397008B38E0 /* JamfProAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03270BF28636397008B38E0 /* JamfProAPIClient.swift */; }; + C05844B82AD4512D00141353 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05844B72AD4512D00141353 /* Token.swift */; }; C07961E228749A36007B98A7 /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E128749A36007B98A7 /* TokenTests.swift */; }; C07961E428749A51007B98A7 /* NetworkAuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */; }; C0A85DB5279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig in Resources */ = {isa = PBXBuildFile; fileRef = C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */; }; @@ -112,6 +113,7 @@ C03270B928636330008B38E0 /* SemanticVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; C03270BE28636397008B38E0 /* Networking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; C03270BF28636397008B38E0 /* JamfProAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JamfProAPIClient.swift; sourceTree = ""; }; + C05844B72AD4512D00141353 /* Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; C07961E128749A36007B98A7 /* TokenTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenTests.swift; sourceTree = ""; }; C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAuthManagerTests.swift; sourceTree = ""; }; C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "TestTCCUnsignedProfile-allLower.mobileconfig"; sourceTree = ""; }; @@ -311,6 +313,7 @@ C03270BE28636397008B38E0 /* Networking.swift */, C03270BF28636397008B38E0 /* JamfProAPIClient.swift */, C0EE9A7E2863BDE300738B6B /* JamfProAPITypes.swift */, + C05844B72AD4512D00141353 /* Token.swift */, C0EE9A822863BEEB00738B6B /* URLSessionAsyncCompatibility.swift */, ); path = Networking; @@ -502,6 +505,7 @@ 345B01D623FDBF55008838B6 /* TCCProfileExtensions.swift in Sources */, 6EC409F3214D8FFA00BE4F17 /* TCCProfileViewController.swift in Sources */, 6E6216F9215321CE0043DF18 /* OpenViewController.swift in Sources */, + C05844B82AD4512D00141353 /* Token.swift in Sources */, 6EC40A10214DE3B200BE4F17 /* Executable.swift in Sources */, C0EE9A832863BEEB00738B6B /* URLSessionAsyncCompatibility.swift in Sources */, C0EE9A812863BE2B00738B6B /* NetworkAuthManager.swift in Sources */, diff --git a/PPPC UtilityTests/ModelTests/ModelTests.swift b/PPPC UtilityTests/ModelTests/ModelTests.swift index 57548ae..a07cb54 100644 --- a/PPPC UtilityTests/ModelTests/ModelTests.swift +++ b/PPPC UtilityTests/ModelTests/ModelTests.swift @@ -185,7 +185,6 @@ class ModelTests: XCTestCase { } } - // swiftlint:disable:next function_body_length func testExportProfileWithAppleEventsAndLegacyAllowed() { // given let exe1 = Executable(identifier: "one", codeRequirement: "oneReq") diff --git a/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift b/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift index 33e2047..d54ddd4 100644 --- a/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift +++ b/PPPC UtilityTests/NetworkingTests/NetworkAuthManagerTests.swift @@ -38,12 +38,16 @@ class MockNetworking: Networking { super.init(serverUrlString: "https://example.com", tokenManager: tokenManager) } - override func getBearerToken() async throws -> Token { + override func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { if let error = errorToThrow { throw error } - return Token(value: "xyz", expireTime: "2950-06-22T22:05:58.81Z") + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try XCTUnwrap(formatter.date(from: "2950-06-22T22:05:58.81Z")) + + return Token(value: "xyz", expiresAt: expiration) } } diff --git a/PPPC UtilityTests/NetworkingTests/TokenTests.swift b/PPPC UtilityTests/NetworkingTests/TokenTests.swift index 3ad32c3..973e1d5 100644 --- a/PPPC UtilityTests/NetworkingTests/TokenTests.swift +++ b/PPPC UtilityTests/NetworkingTests/TokenTests.swift @@ -31,9 +31,12 @@ import XCTest @testable import PPPC_Utility class TokenTests: XCTestCase { - func testPastIsNotValid() { + func testPastIsNotValid() throws { // given - let token = Token(value: "abc", expireTime: "2021-06-22T22:05:58.81Z") + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try XCTUnwrap(formatter.date(from: "2021-06-22T22:05:58.81Z")) + let token = Token(value: "abc", expiresAt: expiration) // when let valid = token.isValid @@ -42,9 +45,12 @@ class TokenTests: XCTestCase { XCTAssertFalse(valid) } - func testFutureIsValid() { + func testFutureIsValid() throws { // given - let token = Token(value: "abc", expireTime: "2750-06-22T22:05:58.81Z") + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiration = try XCTUnwrap(formatter.date(from: "2750-06-22T22:05:58.81Z")) + let token = Token(value: "abc", expiresAt: expiration) // when let valid = token.isValid @@ -52,4 +58,68 @@ class TokenTests: XCTestCase { // then XCTAssertTrue(valid) } + + // MARK: - Decoding + + func testDecodeBasicAuthToken() throws { + // given + let jsonText = """ + { + "token": "abc", + "expires": "2750-06-22T22:05:58.81Z" + } + """ + let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + XCTAssertEqual(actual.value, "abc") + XCTAssertNotNil(actual.expiresAt) + XCTAssertTrue(actual.isValid) + } + + func testDecodeExpiredBasicAuthToken() throws { + // given + let jsonText = """ + { + "token": "abc", + "expires": "1970-10-24T22:05:58.81Z" + } + """ + let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + XCTAssertEqual(actual.value, "abc") + XCTAssertNotNil(actual.expiresAt) + XCTAssertFalse(actual.isValid) + } + + func testDecodeClientCredentialsAuthToken() throws { + // given + let jsonText = """ + { + "access_token": "abc", + "scope": "api-role:2", + "token_type": "Bearer", + "expires_in": 599 + } + """ + let jsonData = try XCTUnwrap(jsonText.data(using: .utf8)) + let decoder = JSONDecoder() + + // when + let actual = try decoder.decode(Token.self, from: jsonData) + + // then + XCTAssertEqual(actual.value, "abc") + XCTAssertNotNil(actual.expiresAt) + XCTAssertTrue(actual.isValid) + } } diff --git a/Source/Networking/JamfProAPIClient.swift b/Source/Networking/JamfProAPIClient.swift index b57584f..3f98316 100644 --- a/Source/Networking/JamfProAPIClient.swift +++ b/Source/Networking/JamfProAPIClient.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2022 Jamf Software +// Copyright (c) 2023 Jamf Software // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -30,14 +30,28 @@ import Foundation class JamfProAPIClient: Networking { let applicationJson = "application/json" - override func getBearerToken() async throws -> Token { - let endpoint = "api/v1/auth/token" - var request = try url(forEndpoint: endpoint) + override func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { + switch authInfo { + case .basicAuth: + let endpoint = "api/v1/auth/token" + var request = try url(forEndpoint: endpoint) - request.httpMethod = "POST" - request.setValue(applicationJson, forHTTPHeaderField: "Accept") + request.httpMethod = "POST" + request.setValue(applicationJson, forHTTPHeaderField: "Accept") + + return try await loadBasicAuthorized(request: request) + case .clientCreds(let id, let secret): + let endpoint = "api/oauth/token" + var request = try url(forEndpoint: endpoint) + + request.httpMethod = "POST" + request.setValue(applicationJson, forHTTPHeaderField: "Accept") + request.setValue("client_credentials", forHTTPHeaderField: "grant_type") + request.setValue(id, forHTTPHeaderField: "client_id") + request.setValue(secret, forHTTPHeaderField: "client_secret") - return try await loadBasicAuthorized(request: request) + return try await loadPreAuthorized(request: request) + } } // MARK: - Requests with fallback auth diff --git a/Source/Networking/NetworkAuthManager.swift b/Source/Networking/NetworkAuthManager.swift index 2cd3181..b45f74b 100644 --- a/Source/Networking/NetworkAuthManager.swift +++ b/Source/Networking/NetworkAuthManager.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2022 Jamf Software +// Copyright (c) 2023 Jamf Software // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -39,10 +39,16 @@ enum AuthError: Error, Equatable { case bearerAuthNotSupported } +/// Support two main ways to authenticate to the Jamf Pro API. +enum AuthenticationInfo { + case basicAuth(username: String, password: String) + + case clientCreds(id: String, secret: String) +} + /// This actor ensures that only one token refresh occurs at the same time. actor NetworkAuthManager { - private let username: String - private let password: String + private let authInfo: AuthenticationInfo private var currentToken: Token? private var refreshTask: Task? @@ -50,11 +56,14 @@ actor NetworkAuthManager { private var supportsBearerAuth = true init(username: String, password: String) { - self.username = username - self.password = password + authInfo = .basicAuth(username: username, password: password) } - func validToken(networking: Networking) async throws -> Token { + init(clientId: String, clientSecret: String) { + authInfo = .clientCreds(id: clientId, secret: clientSecret) + } + + func validToken(networking: Networking) async throws -> Token { if let task = refreshTask { // A refresh is already running; we'll use those results when ready. return try await task.value @@ -79,7 +88,7 @@ actor NetworkAuthManager { defer { refreshTask = nil } do { - let newToken = try await networking.getBearerToken() + let newToken = try await networking.getBearerToken(authInfo: authInfo) currentToken = newToken return newToken } catch NetworkingError.serverResponse(let responseCode, _) where responseCode == 404 { @@ -111,7 +120,8 @@ actor NetworkAuthManager { /// This doesn't mutate any state and only accesses `let` constants so it doesn't need to be actor isolated. /// - Returns: The encoded data string for use with Basic Auth. nonisolated func basicAuthString() throws -> String { - guard !username.isEmpty && !password.isEmpty, + guard case .basicAuth(let username, let password) = authInfo, + !username.isEmpty && !password.isEmpty, let result = "\(username):\(password)".data(using: .utf8)?.base64EncodedString(), !result.isEmpty else { throw AuthError.invalidUsernamePassword @@ -119,22 +129,3 @@ actor NetworkAuthManager { return result } } - -struct Token: Decodable { - let value: String - let expireTime: String - - var isValid: Bool { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: expireTime) { - return date > Date() - } - return false - } - - enum CodingKeys: String, CodingKey { - case value = "token" - case expireTime = "expires" - } -} diff --git a/Source/Networking/Networking.swift b/Source/Networking/Networking.swift index 48101e2..2c45d10 100644 --- a/Source/Networking/Networking.swift +++ b/Source/Networking/Networking.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2022 Jamf Software +// Copyright (c) 2023 Jamf Software // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -39,7 +39,7 @@ enum NetworkingError: Error, Equatable { /// The first associated value is the HTTP response code. The second associated value is the full URL that was attempted. case serverResponse(Int, String) - /// This is thrown if a subclass does not implement ``getBearerToken()`` and then attempts to use bearer tokens. + /// This is thrown if a subclass does not implement ``getBearerToken(authInfo:)`` and then attempts to use bearer tokens. case unimplemented } @@ -54,7 +54,7 @@ class Networking { /// Subclasses must override this to do a network call to return a bearer token. /// - Returns: A token - func getBearerToken() async throws -> Token { + func getBearerToken(authInfo: AuthenticationInfo) async throws -> Token { throw NetworkingError.unimplemented } @@ -71,7 +71,27 @@ class Networking { return URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45.0) } - /// Sends a `URLRequest` and decodes a response to a Basic Auth protected endpoint. + /// Sends a `URLRequest` and decodes a response to an endpoint. + /// - Parameter request: A request that already has authorization info. + /// - Returns: The result. + func loadPreAuthorized(request: URLRequest) async throws -> T { + let (data, urlResponse) = try await URLSession.shared.data(for: request) + + if let httpResponse = urlResponse as? HTTPURLResponse { + if httpResponse.statusCode == 401 { + throw AuthError.invalidUsernamePassword + } else if !(200...299).contains(httpResponse.statusCode) { + throw NetworkingError.serverResponse(httpResponse.statusCode, request.url?.absoluteString ?? "") + } + } + + let decoder = JSONDecoder() + let response = try decoder.decode(T.self, from: data) + + return response + } + + /// Sends a `URLRequest` and decodes a response to a Basic Auth protected endpoint. /// - Parameter request: A request that does not yet include authorization info. /// - Returns: The result. func loadBasicAuthorized(request: URLRequest) async throws -> T { diff --git a/Source/Networking/Token.swift b/Source/Networking/Token.swift new file mode 100644 index 0000000..d4287e3 --- /dev/null +++ b/Source/Networking/Token.swift @@ -0,0 +1,81 @@ +// +// Token.swift +// PPPC Utility +// +// MIT License +// +// Copyright (c) 2023 Jamf Software +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Network authentication token for Jamf Pro connection. +/// +/// Decodes network response for authentication tokens from Jamf Pro for both the newer OAuth client credentials flow +/// and the older basic-auth-based flow. +struct Token: Decodable { + let value: String + let expiresAt: Date? + + var isValid: Bool { + if let expiration = expiresAt { + return expiration > Date() + } + + return true + } + + enum OAuthTokenCodingKeys: String, CodingKey { + case value = "access_token" + case expire = "expires_in" + } + + enum BasicAuthCodingKeys: String, CodingKey { + case value = "token" + case expireTime = "expires" + } + + init(from decoder: Decoder) throws { + // First try to decode with oauth client credentials token response + let container = try decoder.container(keyedBy: OAuthTokenCodingKeys.self) + let possibleValue = try? container.decode(String.self, forKey: .value) + if let value = possibleValue { + self.value = value + let expireIn = try container.decode(Double.self, forKey: .expire) + self.expiresAt = Date().addingTimeInterval(expireIn) + return + } + + // If that fails try to decode with basic auth token response + let container1 = try decoder.container(keyedBy: BasicAuthCodingKeys.self) + self.value = try container1.decode(String.self, forKey: .value) + let expireTime = try container1.decode(String.self, forKey: .expireTime) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.expiresAt = formatter.date(from: expireTime) + } + + init(value: String, expiresAt: Date) { + self.value = value + self.expiresAt = expiresAt + } +} From b1ec69e2627b6954c178880871af72e81e1de07b Mon Sep 17 00:00:00 2001 From: Kyle Hammond Date: Fri, 1 Dec 2023 12:53:34 -0600 Subject: [PATCH 2/6] #120 Connection to Jamf Pro can use OAuth credentials at the networking layer. --- CHANGELOG.md | 2 +- PPPC Utility.xcodeproj/project.pbxproj | 5 +++ .../JamfProAPIClientTests.swift | 27 +++++++++++ README.md | 10 +++++ Source/Networking/JamfProAPIClient.swift | 45 +++++++++++++++---- Source/Networking/Token.swift | 22 +-------- 6 files changed, 80 insertions(+), 31 deletions(-) create mode 100644 PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 382d149..d7c11ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] -N/A +- Connection to Jamf Pro can now use client credentials with Jamf Pro v10.49+ ## [1.5.0] - 2022-10-04 diff --git a/PPPC Utility.xcodeproj/project.pbxproj b/PPPC Utility.xcodeproj/project.pbxproj index 2be6809..e2732e9 100644 --- a/PPPC Utility.xcodeproj/project.pbxproj +++ b/PPPC Utility.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ C05844B82AD4512D00141353 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05844B72AD4512D00141353 /* Token.swift */; }; C07961E228749A36007B98A7 /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E128749A36007B98A7 /* TokenTests.swift */; }; C07961E428749A51007B98A7 /* NetworkAuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */; }; + C0A2B5422B1A5D5C0007F510 /* JamfProAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A2B5412B1A5D5C0007F510 /* JamfProAPIClientTests.swift */; }; C0A85DB5279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig in Resources */ = {isa = PBXBuildFile; fileRef = C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */; }; C0E0383F27A30C7100A23FA2 /* PPPCServiceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E0383D27A30C7100A23FA2 /* PPPCServiceInfo.swift */; }; C0E0384027A30C7100A23FA2 /* PPPCServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E0383E27A30C7100A23FA2 /* PPPCServicesManager.swift */; }; @@ -116,6 +117,7 @@ C05844B72AD4512D00141353 /* Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; C07961E128749A36007B98A7 /* TokenTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenTests.swift; sourceTree = ""; }; C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAuthManagerTests.swift; sourceTree = ""; }; + C0A2B5412B1A5D5C0007F510 /* JamfProAPIClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JamfProAPIClientTests.swift; sourceTree = ""; }; C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "TestTCCUnsignedProfile-allLower.mobileconfig"; sourceTree = ""; }; C0E0383D27A30C7100A23FA2 /* PPPCServiceInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPPCServiceInfo.swift; sourceTree = ""; }; C0E0383E27A30C7100A23FA2 /* PPPCServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPPCServicesManager.swift; sourceTree = ""; }; @@ -322,6 +324,7 @@ C07961E028749A36007B98A7 /* NetworkingTests */ = { isa = PBXGroup; children = ( + C0A2B5412B1A5D5C0007F510 /* JamfProAPIClientTests.swift */, C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */, C07961E128749A36007B98A7 /* TokenTests.swift */, ); @@ -444,6 +447,7 @@ /* Begin PBXShellScriptBuildPhase section */ 49DB95D624991AA800F433CA /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -469,6 +473,7 @@ files = ( C07961E228749A36007B98A7 /* TokenTests.swift in Sources */, 34DED4D423FDCAFD00C53FB9 /* SwiftyCMSDecoder.swift in Sources */, + C0A2B5422B1A5D5C0007F510 /* JamfProAPIClientTests.swift in Sources */, C07961E428749A51007B98A7 /* NetworkAuthManagerTests.swift in Sources */, 5F95AE262315A7CB002E0A22 /* TCCProfileImporterTests.swift in Sources */, C01BEDBA28636F57001B0B3B /* SemanticVersionTests.swift in Sources */, diff --git a/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift b/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift new file mode 100644 index 0000000..308decd --- /dev/null +++ b/PPPC UtilityTests/NetworkingTests/JamfProAPIClientTests.swift @@ -0,0 +1,27 @@ +// +// JamfProAPIClientTests.swift +// PPPC UtilityTests +// +// SPDX-License-Identifier: MIT +// Copyright (c) 2023 Jamf Software + +import Foundation +import XCTest + +@testable import PPPC_Utility + +class JamfProAPIClientTests: XCTestCase { + func testOAuthTokenRequest() throws { + // given + let authManager = NetworkAuthManager(username: "", password: "") + let apiClient = JamfProAPIClient(serverUrlString: "https://something", tokenManager: authManager) + + // when + let request = try apiClient.oauthTokenRequest(clientId: "mine&yours", clientSecret: "foo bar") + + // then + let body = try XCTUnwrap(request.httpBody) + let bodyString = String(data: body, encoding: .utf8) + XCTAssertEqual(bodyString, "grant_type=client_credentials&client_id=mine%26yours&client_secret=foo%20bar") + } +} diff --git a/README.md b/README.md index 79b07ad..ed095a1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,16 @@ Profiles can be saved locally either signed or unsigned. ## Upload to Jamf Pro +PPPC Utility can use bearer token authentication (or basic authentication as a fallback for versions of Jamf Pro older than v10.34) to any supported +Jamf Pro version using the username and password of a Jamf Pro user account. The user account at minimum needs the two privileges indicated below. + +Jamf Pro 10.49 and higher can use OAuth client credentials to access the API. The client ID and client secret generated by Jamf Pro in the +"API Roles and clients" settings are used during the PPPC Utility upload process. When setting up the API Role, these are the permissions that +PPPC Utility requires to upload the profiles. + +- "Create macOS Configuration Profiles" - primary permission to upload profiles; each upload from PPPC Utility creates a new profile. +- "Read Activation Code" - needed to retrieve the organization name that is placed in the profile. + ### Jamf Pro 10.7.1 and newer Starting in Jamf Pro 10.7.1 the Privacy Preferences Policy Control Payload can be uploaded to the API without being signed before uploading. diff --git a/Source/Networking/JamfProAPIClient.swift b/Source/Networking/JamfProAPIClient.swift index 3f98316..2d1d695 100644 --- a/Source/Networking/JamfProAPIClient.swift +++ b/Source/Networking/JamfProAPIClient.swift @@ -41,20 +41,35 @@ class JamfProAPIClient: Networking { return try await loadBasicAuthorized(request: request) case .clientCreds(let id, let secret): - let endpoint = "api/oauth/token" - var request = try url(forEndpoint: endpoint) - - request.httpMethod = "POST" - request.setValue(applicationJson, forHTTPHeaderField: "Accept") - request.setValue("client_credentials", forHTTPHeaderField: "grant_type") - request.setValue(id, forHTTPHeaderField: "client_id") - request.setValue(secret, forHTTPHeaderField: "client_secret") + let request = try oauthTokenRequest(clientId: id, clientSecret: secret) return try await loadPreAuthorized(request: request) } } - // MARK: - Requests with fallback auth + /// Creates the OAuth client credentials token request + /// - Parameters: + /// - clientId: The client ID + /// - clientSecret: The client secret + /// - Returns: A `URLRequest` that is ready to send to acquire an OAuth token. + func oauthTokenRequest(clientId: String, clientSecret: String) throws -> URLRequest { + let endpoint = "api/oauth/token" + var request = try url(forEndpoint: endpoint) + + request.httpMethod = "POST" + request.setValue(applicationJson, forHTTPHeaderField: "Accept") + + var components = URLComponents() + components.queryItems = [URLQueryItem(name: "grant_type", value: "client_credentials"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "client_secret", value: clientSecret)] + + request.httpBody = components.percentEncodedQuery?.data(using: .utf8) + + return request + } + + // MARK: - Requests with fallback auth /// Make a network request and decode the response using bearer auth if possible, falling back to basic auth if needed. /// - Parameter request: The `URLRequest` to make @@ -100,6 +115,10 @@ class JamfProAPIClient: Networking { // MARK: - Useful API endpoints + /// Reads the Jamf Pro organization name + /// + /// Requires "Read Activation Code" permission in the API + /// - Parameter profileData: The prepared profile data func getOrganizationName() async throws -> String { let endpoint = "JSSResource/activationcode" var request = try url(forEndpoint: endpoint) @@ -113,6 +132,10 @@ class JamfProAPIClient: Networking { return info.activationCode.organizationName } + /// Gets the Jamf Pro version + /// + /// No specific permissions required. + /// - Returns: The Jamf Pro server version func getJamfProVersion() async throws -> JamfProVersion { let endpoint = "api/v1/jamf-pro-version" var request = try url(forEndpoint: endpoint) @@ -133,6 +156,10 @@ class JamfProAPIClient: Networking { return info } + /// Uploads a computer configuration profile + /// + /// Requires "Create macOS Configuration Profiles" permission in the API + /// - Parameter profileData: The prepared profile data func upload(computerConfigProfile profileData: Data) async throws { let endpoint = "JSSResource/osxconfigurationprofiles" var request = try url(forEndpoint: endpoint) diff --git a/Source/Networking/Token.swift b/Source/Networking/Token.swift index d4287e3..3501fe9 100644 --- a/Source/Networking/Token.swift +++ b/Source/Networking/Token.swift @@ -2,28 +2,8 @@ // Token.swift // PPPC Utility // -// MIT License -// +// SPDX-License-Identifier: MIT // Copyright (c) 2023 Jamf Software -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// import Foundation From 3b2afb374ed64a49a506d2cbc55664a076b7271c Mon Sep 17 00:00:00 2001 From: Kyle Hammond Date: Sun, 3 Dec 2023 11:51:10 -0600 Subject: [PATCH 3/6] #120 Replaced Upload sheet in the storyboard with a SwiftUI view that handles the Upload sheet, including the new Client Credentials options. Refactored the actual upload networking code out into an UploadManager type. --- PPPC Utility.xcodeproj/project.pbxproj | 22 +- Resources/Base.lproj/Main.storyboard | 775 +++--------------- Source/Networking/UploadManager.swift | 81 ++ Source/SecurityWrapper.swift | 3 +- Source/SwiftUI/UploadInfoView.swift | 351 ++++++++ .../TCCProfileViewController.swift | 20 + 6 files changed, 605 insertions(+), 647 deletions(-) create mode 100644 Source/Networking/UploadManager.swift create mode 100644 Source/SwiftUI/UploadInfoView.swift diff --git a/PPPC Utility.xcodeproj/project.pbxproj b/PPPC Utility.xcodeproj/project.pbxproj index e2732e9..ecfba9a 100644 --- a/PPPC Utility.xcodeproj/project.pbxproj +++ b/PPPC Utility.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -45,8 +45,10 @@ C03270C028636397008B38E0 /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03270BE28636397008B38E0 /* Networking.swift */; }; C03270C128636397008B38E0 /* JamfProAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03270BF28636397008B38E0 /* JamfProAPIClient.swift */; }; C05844B82AD4512D00141353 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05844B72AD4512D00141353 /* Token.swift */; }; + C05844BE2AD45F7900141353 /* UploadInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05844BD2AD45F7900141353 /* UploadInfoView.swift */; }; C07961E228749A36007B98A7 /* TokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E128749A36007B98A7 /* TokenTests.swift */; }; C07961E428749A51007B98A7 /* NetworkAuthManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */; }; + C07B1FB82AF596D80075E38B /* UploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07B1FB72AF596D80075E38B /* UploadManager.swift */; }; C0A2B5422B1A5D5C0007F510 /* JamfProAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A2B5412B1A5D5C0007F510 /* JamfProAPIClientTests.swift */; }; C0A85DB5279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig in Resources */ = {isa = PBXBuildFile; fileRef = C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */; }; C0E0383F27A30C7100A23FA2 /* PPPCServiceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E0383D27A30C7100A23FA2 /* PPPCServiceInfo.swift */; }; @@ -115,8 +117,10 @@ C03270BE28636397008B38E0 /* Networking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; C03270BF28636397008B38E0 /* JamfProAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JamfProAPIClient.swift; sourceTree = ""; }; C05844B72AD4512D00141353 /* Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = ""; }; + C05844BD2AD45F7900141353 /* UploadInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadInfoView.swift; sourceTree = ""; }; C07961E128749A36007B98A7 /* TokenTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenTests.swift; sourceTree = ""; }; C07961E328749A51007B98A7 /* NetworkAuthManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkAuthManagerTests.swift; sourceTree = ""; }; + C07B1FB72AF596D80075E38B /* UploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadManager.swift; sourceTree = ""; }; C0A2B5412B1A5D5C0007F510 /* JamfProAPIClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JamfProAPIClientTests.swift; sourceTree = ""; }; C0A85DB4279873C600086283 /* TestTCCUnsignedProfile-allLower.mobileconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "TestTCCUnsignedProfile-allLower.mobileconfig"; sourceTree = ""; }; C0E0383D27A30C7100A23FA2 /* PPPCServiceInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPPCServiceInfo.swift; sourceTree = ""; }; @@ -261,6 +265,7 @@ 6EC40A11214DF8FE00BE4F17 /* SecurityWrapper.swift */, 6E651CC623143969001CC974 /* Views */, 6EC40A1E214EF89600BE4F17 /* View Controllers */, + C05844BC2AD45F7900141353 /* SwiftUI */, 6EC40A1D214EF87E00BE4F17 /* Model */, C03270BD28636397008B38E0 /* Networking */, 5F95AE0B23158AB5002E0A22 /* TCCProfileImporter */, @@ -313,6 +318,7 @@ children = ( C0EE9A802863BE2B00738B6B /* NetworkAuthManager.swift */, C03270BE28636397008B38E0 /* Networking.swift */, + C07B1FB72AF596D80075E38B /* UploadManager.swift */, C03270BF28636397008B38E0 /* JamfProAPIClient.swift */, C0EE9A7E2863BDE300738B6B /* JamfProAPITypes.swift */, C05844B72AD4512D00141353 /* Token.swift */, @@ -321,6 +327,14 @@ path = Networking; sourceTree = ""; }; + C05844BC2AD45F7900141353 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + C05844BD2AD45F7900141353 /* UploadInfoView.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; C07961E028749A36007B98A7 /* NetworkingTests */ = { isa = PBXGroup; children = ( @@ -503,6 +517,7 @@ 6EC40A18214ECF2C00BE4F17 /* UploadViewController.swift in Sources */, C03270C128636397008B38E0 /* JamfProAPIClient.swift in Sources */, C0E0384027A30C7100A23FA2 /* PPPCServicesManager.swift in Sources */, + C07B1FB82AF596D80075E38B /* UploadManager.swift in Sources */, 6E651CCA231439CE001CC974 /* InfoButton.swift in Sources */, 6EC409F5214D95D200BE4F17 /* TCCProfile.swift in Sources */, 6EC40A12214DF8FE00BE4F17 /* SecurityWrapper.swift in Sources */, @@ -515,6 +530,7 @@ C0EE9A832863BEEB00738B6B /* URLSessionAsyncCompatibility.swift in Sources */, C0EE9A812863BE2B00738B6B /* NetworkAuthManager.swift in Sources */, 6EC40A16214ECF1E00BE4F17 /* SaveViewController.swift in Sources */, + C05844BE2AD45F7900141353 /* UploadInfoView.swift in Sources */, 6EB45830214FFCCB00BE5749 /* AppleEventRule.swift in Sources */, 5F90EBDF2319970000738D09 /* TCCProfileImportError.swift in Sources */, C03270BA28636330008B38E0 /* SemanticVersion.swift in Sources */, @@ -643,7 +659,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IBSC_NOTICES = NO; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -699,7 +715,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IBSC_NOTICES = NO; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; diff --git a/Resources/Base.lproj/Main.storyboard b/Resources/Base.lproj/Main.storyboard index a50430a..7b15d80 100644 --- a/Resources/Base.lproj/Main.storyboard +++ b/Resources/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -723,7 +723,7 @@ - + @@ -797,13 +797,13 @@ - + - + @@ -811,7 +811,7 @@ - + @@ -861,7 +861,7 @@ - + @@ -869,7 +869,7 @@ - + @@ -919,7 +919,7 @@ - + @@ -927,7 +927,7 @@ - + @@ -979,7 +979,7 @@ - + @@ -987,7 +987,7 @@ - + @@ -1036,7 +1036,7 @@ - + @@ -1044,10 +1044,7 @@ - - - - + @@ -1059,6 +1056,9 @@ + + + @@ -1098,7 +1098,7 @@ - + @@ -1106,7 +1106,7 @@ - + @@ -1155,7 +1155,7 @@ - + @@ -1163,7 +1163,7 @@ - + @@ -1212,7 +1212,7 @@ - + @@ -1220,7 +1220,7 @@ - + @@ -1269,7 +1269,7 @@ - + @@ -1277,7 +1277,7 @@ - + @@ -1326,7 +1326,7 @@ - + @@ -1334,7 +1334,7 @@ - + @@ -1384,7 +1384,7 @@ - + @@ -1392,7 +1392,7 @@ - + @@ -1441,7 +1441,7 @@ - + @@ -1449,7 +1449,7 @@ - + @@ -1498,7 +1498,7 @@ - + @@ -1506,7 +1506,7 @@ - + @@ -1555,7 +1555,7 @@ - + @@ -1563,7 +1563,7 @@ - + @@ -1612,7 +1612,7 @@ - + @@ -1620,7 +1620,7 @@ - + @@ -1670,7 +1670,7 @@ - + @@ -1678,7 +1678,7 @@ - + @@ -1728,7 +1728,7 @@ - + @@ -1736,7 +1736,7 @@ - + @@ -1786,7 +1786,7 @@ - + @@ -1794,7 +1794,7 @@ - + @@ -1843,7 +1843,7 @@ - + @@ -1851,7 +1851,7 @@ - + @@ -1900,7 +1900,7 @@ - + @@ -1908,7 +1908,7 @@ - + @@ -2070,7 +2070,7 @@ - + @@ -2096,7 +2096,7 @@ - + @@ -2140,10 +2140,7 @@ - - - - + @@ -2154,6 +2151,9 @@ + + + @@ -2204,18 +2204,18 @@ - + @@ -2398,7 +2398,7 @@ - + @@ -2412,7 +2412,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSIsNil - - - - - - [Required] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSIsNil - - - - - - [Required] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSIsNil - - - - - - [Required] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSIsNil - - - - - - [Optional] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NSIsNil - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Id - - - - - - - - - - - - - - - - Name - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3275,7 +2764,7 @@ Gw - + @@ -3290,7 +2779,7 @@ Gw - + @@ -3300,7 +2789,7 @@ Gw - + @@ -3318,7 +2807,7 @@ Gw - + @@ -3328,7 +2817,7 @@ Gw - + @@ -3346,7 +2835,7 @@ Gw - + @@ -3356,7 +2845,7 @@ Gw - + @@ -3374,7 +2863,7 @@ Gw - + @@ -3384,7 +2873,7 @@ Gw - + @@ -3402,7 +2891,7 @@ Gw - + @@ -3412,7 +2901,7 @@ Gw - + @@ -3433,7 +2922,7 @@ Gw