diff --git a/Sources/AuthFoundation/Responses/GrantType.swift b/Sources/AuthFoundation/Responses/GrantType.swift index 83a117b89..c01f27ef5 100644 --- a/Sources/AuthFoundation/Responses/GrantType.swift +++ b/Sources/AuthFoundation/Responses/GrantType.swift @@ -20,6 +20,7 @@ public enum GrantType: Codable, Hashable, IsClaim { case password case deviceCode case tokenExchange + case jwtBearer case otp case oob case otpMFA @@ -35,6 +36,7 @@ private let grantTypeMapping: [String: GrantType] = [ "refresh_token": .refreshToken, "password": .password, "urn:ietf:params:oauth:grant-type:token-exchange": .tokenExchange, + "urn:ietf:params:oauth:grant-type:jwt-bearer": .jwtBearer, "urn:ietf:params:oauth:grant-type:device_code": .deviceCode, "urn:okta:params:oauth:grant-type:otp": .otp, "urn:okta:params:oauth:grant-type:oob": .oob, @@ -69,6 +71,8 @@ extension GrantType: RawRepresentable { return "password" case .tokenExchange: return "urn:ietf:params:oauth:grant-type:token-exchange" + case .jwtBearer: + return "urn:ietf:params:oauth:grant-type:jwt-bearer" case .deviceCode: return "urn:ietf:params:oauth:grant-type:device_code" case .otp: diff --git a/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift index 65c2360a5..ef6f4142a 100644 --- a/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift +++ b/Sources/OktaOAuth2/Authentication/AuthorizationCodeFlow.swift @@ -365,6 +365,11 @@ extension AuthorizationCodeFlow: OAuth2ClientDelegate { } extension OAuth2Client { + /// Creates a new Authorization Code flow configured to use this OAuth2Client. + /// - Parameters: + /// - redirectUri: Redirect URI + /// - additionalParameters: Additional parameters to pass to the flow + /// - Returns: Initialized authorization flow. public func authorizationCodeFlow( redirectUri: URL, additionalParameters: [String: String]? = nil) -> AuthorizationCodeFlow diff --git a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift index 22b283d1d..45c751b3f 100644 --- a/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift +++ b/Sources/OktaOAuth2/Authentication/DeviceAuthorizationFlow.swift @@ -370,6 +370,8 @@ extension DeviceAuthorizationFlow: OAuth2ClientDelegate { } extension OAuth2Client { + /// Creates a new Device Authorization flow configured to use this OAuth2Client. + /// - Returns: Initialized authorization flow. public func deviceAuthorizationFlow() -> DeviceAuthorizationFlow { DeviceAuthorizationFlow(client: self) } diff --git a/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift b/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift new file mode 100644 index 000000000..7fbbb1658 --- /dev/null +++ b/Sources/OktaOAuth2/Authentication/JWTAuthorizationFlow.swift @@ -0,0 +1,151 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// + +import Foundation +import AuthFoundation + +/// An authentication flow class that implements the JWT Authorization Bearer Flow, for authenticating users using JWTs signed by a trusted key. +public class JWTAuthorizationFlow: AuthenticationFlow { + /// The OAuth2Client this authentication flow will use. + public let client: OAuth2Client + + /// Indicates whether or not this flow is currently in the process of authenticating a user. + /// ``JWTAuthorizationFlow/init(issuer:clientId:scopes:)`` + public private(set) var isAuthenticating: Bool = false { + didSet { + guard oldValue != isAuthenticating else { + return + } + + if isAuthenticating { + delegateCollection.invoke { $0.authenticationStarted(flow: self) } + } else { + delegateCollection.invoke { $0.authenticationFinished(flow: self) } + } + } + } + + /// Convenience initializer to construct an authentication flow from variables. + /// - Parameters: + /// - issuer: The issuer URL. + /// - clientId: The client ID + /// - scopes: The scopes to request + public convenience init(issuer: URL, + clientId: String, + scopes: String) + { + self.init(client: OAuth2Client(baseURL: issuer, + clientId: clientId, + scopes: scopes)) + } + + /// Initializer to construct an authentication flow from an OAuth2Client. + /// - Parameter client: `OAuth2Client` instance to authenticate with. + public init(client: OAuth2Client) { + // Ensure this SDK's static version is included in the user agent. + SDKVersion.register(sdk: Version) + + self.client = client + + client.add(delegate: self) + } + + /// Initializer that uses the configuration defined within the application's `Okta.plist` file. + public convenience init() throws { + self.init(try OAuth2Client.PropertyListConfiguration()) + } + + /// Initializer that uses the configuration defined within the given file URL. + /// - Parameter fileURL: File URL to a `plist` containing client configuration. + public convenience init(plist fileURL: URL) throws { + self.init(try OAuth2Client.PropertyListConfiguration(plist: fileURL)) + } + + private convenience init(_ config: OAuth2Client.PropertyListConfiguration) { + self.init(issuer: config.issuer, + clientId: config.clientId, + scopes: config.scopes) + } + + /// Authenticates using the supplied JWT bearer assertion. + /// - Parameters: + /// - assertion: JWT Assertion + /// - completion: Completion invoked when a response is received. + public func start(with assertion: JWT, completion: @escaping (Result) -> Void) { + isAuthenticating = true + + client.openIdConfiguration { result in + switch result { + case .success(let configuration): + let request = TokenRequest(openIdConfiguration: configuration, + clientId: self.client.configuration.clientId, + scope: self.client.configuration.scopes, + assertion: assertion) + self.client.exchange(token: request) { result in + self.reset() + + switch result { + case .failure(let error): + self.delegateCollection.invoke { $0.authentication(flow: self, received: .network(error: error)) } + completion(.failure(.network(error: error))) + case .success(let response): + self.delegateCollection.invoke { $0.authentication(flow: self, received: response.result) } + completion(.success(response.result)) + } + } + + case .failure(let error): + self.delegateCollection.invoke { $0.authentication(flow: self, received: error) } + completion(.failure(error)) + } + } + } + + /// Resets the flow for later reuse. + public func reset() { + isAuthenticating = false + } + + // MARK: Private properties / methods + public let delegateCollection = DelegateCollection() +} + +#if swift(>=5.5.1) +@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) +extension JWTAuthorizationFlow { + /// Asynchronously authenticates with a JWT bearer assertion. + /// + /// - Parameter jwt: JWT Assertion + /// - Returns: The token resulting from signing in. + public func start(with assertion: JWT) async throws -> Token { + try await withCheckedThrowingContinuation { continuation in + start(with: assertion) { result in + continuation.resume(with: result) + } + } + } +} +#endif + +extension JWTAuthorizationFlow: UsesDelegateCollection { + public typealias Delegate = AuthenticationDelegate +} + +extension JWTAuthorizationFlow: OAuth2ClientDelegate {} + +extension OAuth2Client { + /// Creates a new JWT Authorization flow configured to use this OAuth2Client. + /// - Returns: Initialized authorization flow. + public func jwtAuthorizationFlow() -> JWTAuthorizationFlow { + JWTAuthorizationFlow(client: self) + } +} diff --git a/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift b/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift index f1d2cbd64..cef282264 100644 --- a/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift +++ b/Sources/OktaOAuth2/Authentication/ResourceOwnerFlow.swift @@ -148,6 +148,8 @@ extension ResourceOwnerFlow: OAuth2ClientDelegate { } extension OAuth2Client { + /// Creates a new Resource Owner flow configured to use this OAuth2Client. + /// - Returns: Initialized authorization flow. public func resourceOwnerFlow() -> ResourceOwnerFlow { ResourceOwnerFlow(client: self) } diff --git a/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift b/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift index 17718844a..46ea98233 100644 --- a/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift +++ b/Sources/OktaOAuth2/Authentication/SessionTokenFlow.swift @@ -264,6 +264,11 @@ extension SessionTokenFlow: OAuth2ClientDelegate { } extension OAuth2Client { + /// Creates a new Session Token flow configured to use this OAuth2Client. + /// - Parameters: + /// - redirectUri: Redirect URI + /// - additionalParameters: Additional parameters to pass to the flow + /// - Returns: Initialized authorization flow. public func sessionTokenFlow(redirectUri: URL, additionalParameters: [String: String]? = nil) -> SessionTokenFlow { diff --git a/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift index 3bc024082..e97f5ba35 100644 --- a/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift +++ b/Sources/OktaOAuth2/Authentication/TokenExchangeFlow.swift @@ -172,6 +172,9 @@ extension TokenExchangeFlow { #endif extension OAuth2Client { + /// Creates a new Token Exchange flow configured to use this OAuth2Client, using the supplied arguments. + /// - Parameter audience: Audience to configure the flow to use + /// - Returns: Initialized authorization flow. public func tokenExchangeFlow(audience: TokenExchangeFlow.Audience = .default) -> TokenExchangeFlow { TokenExchangeFlow(audience: audience, client: self) } diff --git a/Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift b/Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift new file mode 100644 index 000000000..7f3b8e6b4 --- /dev/null +++ b/Sources/OktaOAuth2/Requests/JWTAuthorizationFlow+Requests.swift @@ -0,0 +1,43 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// + +import Foundation +import AuthFoundation + +extension JWTAuthorizationFlow { + struct TokenRequest { + let openIdConfiguration: OpenIdConfiguration + let clientId: String + let scope: String + let assertion: JWT + } +} + +extension JWTAuthorizationFlow.TokenRequest: OAuth2TokenRequest, OAuth2APIRequest, APIRequestBody, APIParsingContext { + var bodyParameters: [String: APIRequestArgument]? { + [ + "client_id": clientId, + "scope": scope, + "grant_type": GrantType.jwtBearer, + "assertion": assertion, + ] + } + + var codingUserInfo: [CodingUserInfoKey: Any]? { + [ + .clientSettings: [ + "client_id": clientId, + "scope": scope, + ] + ] + } +} diff --git a/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift b/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift new file mode 100644 index 000000000..242784334 --- /dev/null +++ b/Tests/OktaOAuth2Tests/JWTAuthorizationFlowTests.swift @@ -0,0 +1,165 @@ +// +// Copyright (c) 2024-Present, Okta, Inc. and/or its affiliates. All rights reserved. +// The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") +// +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and limitations under the License. +// + +import XCTest +@testable import TestCommon +@testable import AuthFoundation +@testable import OktaOAuth2 + +final class JWTAuthorizationFlowDelegateRecorder: AuthenticationDelegate { + typealias Flow = JWTAuthorizationFlow + + var token: Token? + var error: OAuth2Error? + var url: URL? + var started = false + var finished = false + + func authenticationStarted(flow: Flow) { + started = true + } + + func authenticationFinished(flow: Flow) { + finished = true + } + + func authentication(flow: Flow, received token: Token) { + self.token = token + } + + func authentication(flow: Flow, received error: OAuth2Error) { + self.error = error + } +} + + +final class JWTAuthorizationFlowTests: XCTestCase { + let issuer = URL(string: "https://example.okta.com")! + let redirectUri = URL(string: "com.example:/callback")! + let urlSession = URLSessionMock() + var client: OAuth2Client! + var flow: JWTAuthorizationFlow! + var jwt: JWT! + + override func setUpWithError() throws { + client = OAuth2Client(baseURL: issuer, + clientId: "clientId", + scopes: "profile openid", + session: urlSession) + JWK.validator = MockJWKValidator() + Token.idTokenValidator = MockIDTokenValidator() + Token.accessTokenValidator = MockTokenHashValidator() + + urlSession.expect("https://example.okta.com/.well-known/openid-configuration", + data: try data(from: .module, for: "openid-configuration", in: "MockResponses"), + contentType: "application/json") + urlSession.expect("https://example.okta.com/oauth2/v1/keys?client_id=clientId", + data: try data(from: .module, for: "keys", in: "MockResponses"), + contentType: "application/json") + urlSession.expect("https://example.okta.com/oauth2/v1/token", + data: try data(from: .module, for: "token", in: "MockResponses"), + contentType: "application/json") + + flow = client.jwtAuthorizationFlow() + + jwt = try JWT(JWT.mockIDToken) + } + + override func tearDownWithError() throws { + JWK.resetToDefault() + Token.resetToDefault() + } + + func testWithDelegate() throws { + let delegate = JWTAuthorizationFlowDelegateRecorder() + flow.add(delegate: delegate) + + XCTAssertFalse(flow.isAuthenticating) + XCTAssertFalse(delegate.started) + + let expect = expectation(description: "Expected `start` succeeded") + flow.start(with: jwt) { result in + expect.fulfill() + } + + waitForExpectations(timeout: 5) { error in + XCTAssertNil(error) + } + + XCTAssertFalse(flow.isAuthenticating) + XCTAssertNotNil(delegate.token) + XCTAssertTrue(delegate.finished) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "assertion=\(JWT.mockIDToken)&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=profile+openid") + } + + func testAuthenticationSucceeded() throws { + let authorizeExpectation = expectation(description: "Expected `start` succeeded") + + XCTAssertFalse(flow.isAuthenticating) + + let expect = expectation(description: "start") + flow.start(with: jwt) { result in + switch result { + case .success: + XCTAssertFalse(self.flow.isAuthenticating) + + authorizeExpectation.fulfill() + case .failure(let error): + XCTFail(error.localizedDescription) + } + + expect.fulfill() + } + waitForExpectations(timeout: 1) { error in + XCTAssertNil(error) + } + + XCTAssertFalse(flow.isAuthenticating) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "assertion=\(JWT.mockIDToken)&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=profile+openid") + } + +#if swift(>=5.5.1) && !os(Linux) + @available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6, *) + func testAsyncAuthenticationSucceeded() async throws { + XCTAssertFalse(flow.isAuthenticating) + + let _ = try await flow.start(with: jwt) + + XCTAssertFalse(flow.isAuthenticating) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "assertion=\(JWT.mockIDToken)&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&scope=profile+openid") + } +#endif +} diff --git a/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift b/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift index 5576d2a63..cf2833747 100644 --- a/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift +++ b/Tests/OktaOAuth2Tests/TokenExchangeFlowTests.swift @@ -97,9 +97,18 @@ final class TokenExchangeFlowTests: XCTestCase { XCTAssertFalse(flow.isAuthenticating) XCTAssertNotNil(delegate.token) XCTAssertTrue(delegate.finished) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "actor_token=secret&actor_token_type=urn:x-oath:params:oauth:token-type:device-secret&audience=api:%2F%2Fdefault&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&scope=profile+openid+device_sso&subject_token=id_token&subject_token_type=urn:ietf:params:oauth:token-type:id_token") } - func testAuthenticationSucceeded() { + func testAuthenticationSucceeded() throws { let authorizeExpectation = expectation(description: "Expected `resume` succeeded") XCTAssertFalse(flow.isAuthenticating) @@ -122,6 +131,15 @@ final class TokenExchangeFlowTests: XCTestCase { } XCTAssertFalse(flow.isAuthenticating) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "actor_token=secret&actor_token_type=urn:x-oath:params:oauth:token-type:device-secret&audience=api:%2F%2Fdefault&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&scope=profile+openid+device_sso&subject_token=id_token&subject_token_type=urn:ietf:params:oauth:token-type:id_token") } #if swift(>=5.5.1) && !os(Linux) @@ -132,6 +150,15 @@ final class TokenExchangeFlowTests: XCTestCase { let _ = try await flow.start(with: tokens) XCTAssertFalse(flow.isAuthenticating) + + XCTAssertEqual(urlSession.requests.count, 3) + + let request = try XCTUnwrap(urlSession.requests.first(where: { request in + request.url?.lastPathComponent == "token" + })) + + XCTAssertEqual(request.url?.absoluteString, "https://example.okta.com/oauth2/v1/token") + XCTAssertEqual(request.bodyString, "actor_token=secret&actor_token_type=urn:x-oath:params:oauth:token-type:device-secret&audience=api:%2F%2Fdefault&client_id=clientId&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&scope=profile+openid+device_sso&subject_token=id_token&subject_token_type=urn:ietf:params:oauth:token-type:id_token") } #endif }