From 537e3ee366d1686143c4aa7efbe03f27b988cbf5 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Sat, 31 May 2025 09:18:08 -0700 Subject: [PATCH 1/2] Code reorganization for improved readability. Using an ephemeral url session now instead of default. Added provider option for setting customUserAgent. Added provider option for explicitly encoding requests into the http body. --- Sources/OAuthKit/OAuth+Authorization.swift | 46 ++ Sources/OAuthKit/OAuth+DeviceCode.swift | 111 ++++ Sources/OAuthKit/OAuth+GrantType.swift | 22 + Sources/OAuthKit/OAuth+Provider.swift | 122 +++++ Sources/OAuthKit/OAuth+Token.swift | 40 ++ Sources/OAuthKit/OAuth.swift | 517 ++++++------------ .../OAuthKit/Views/OAWebViewCoordinator.swift | 18 +- Tests/OAuthKitTests/CodableTests.swift | 1 - Tests/OAuthKitTests/Resources/oauth.json | 27 +- 9 files changed, 545 insertions(+), 359 deletions(-) create mode 100644 Sources/OAuthKit/OAuth+Authorization.swift create mode 100644 Sources/OAuthKit/OAuth+DeviceCode.swift create mode 100644 Sources/OAuthKit/OAuth+GrantType.swift create mode 100644 Sources/OAuthKit/OAuth+Provider.swift create mode 100644 Sources/OAuthKit/OAuth+Token.swift diff --git a/Sources/OAuthKit/OAuth+Authorization.swift b/Sources/OAuthKit/OAuth+Authorization.swift new file mode 100644 index 0000000..7dd00f8 --- /dev/null +++ b/Sources/OAuthKit/OAuth+Authorization.swift @@ -0,0 +1,46 @@ +// +// OAuth+Authorization.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// A codable type that holds authorization information that can be stored. + public struct Authorization: Codable, Equatable, Sendable { + + /// The provider ID that issued the authorization. + public let issuer: String + /// The issue date. + public let issued: Date + /// The issued access token. + public let token: Token + + /// Initializer + /// - Parameters: + /// - issuer: the provider ID that issued the authorization. + /// - token: the access token + /// - issued: the issued date + public init(issuer: String, token: Token, issued: Date = Date.now) { + self.issuer = issuer + self.token = token + self.issued = issued + } + + /// Returns true if the token is expired. + public var isExpired: Bool { + guard let expiresIn = token.expiresIn else { return false } + return issued.addingTimeInterval(Double(expiresIn)) < Date.now + } + + /// Returns the expiration date of the authorization or nil if none exists. + public var expiration: Date? { + guard let expiresIn = token.expiresIn else { return nil } + return issued.addingTimeInterval(TimeInterval(expiresIn)) + } + } + +} diff --git a/Sources/OAuthKit/OAuth+DeviceCode.swift b/Sources/OAuthKit/OAuth+DeviceCode.swift new file mode 100644 index 0000000..942c43c --- /dev/null +++ b/Sources/OAuthKit/OAuth+DeviceCode.swift @@ -0,0 +1,111 @@ +// +// OAuth+DeviceCode.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// A codable type that holds device code information. + /// See: https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow + /// See: https://www.oauth.com/playground/device-code.html + public struct DeviceCode: Codable, Equatable, Sendable { + + /// A constant for the oauth grant type. + static let grantType = "urn:ietf:params:oauth:grant-type:device_code" + + /// The server assigned device code. + public let deviceCode: String + /// The code the user should enter when visiting the `verificationUri` + public let userCode: String + /// The uri the user should visit to enter the `userCode` + public let verificationUri: String + /// Either a QR Code or shortened URL with embedded user code + public let verificationUriComplete: String? + /// The lifetime in seconds for `deviceCode` and `userCode` + public let expiresIn: Int? + /// The polling interval + public let interval: Int + /// The issue date. + public let issued: Date = .now + + /// Returns true if the device code is expired. + public var isExpired: Bool { + guard let expiresIn = expiresIn else { return false } + return issued.addingTimeInterval(Double(expiresIn)) < Date.now + } + + /// Returns the expiration date of the device token or nil if none exists. + public var expiration: Date? { + guard let expiresIn = expiresIn else { return nil } + return issued.addingTimeInterval(TimeInterval(expiresIn)) + } + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationUri = "verification_uri" + /// Google sends `verification_url` instead of `verification_uri` so we need to account for both. + /// See: https://developers.google.com/identity/protocols/oauth2/limited-input-device + case verificationUrl = "verification_url" + case verificationUriComplete = "verification_uri_complete" + case expiresIn = "expires_in" + case interval + } + + /// Public initializer + /// - Parameters: + /// - deviceCode: the device code + /// - userCode: the user code + /// - verificationUri: the verification uri + /// - verificationUriComplete: the qr code or shortened url with embedded user code + /// - expiresIn: lifetime in seconds + /// - interval: the polling interval + public init(deviceCode: String, userCode: String, + verificationUri: String, verificationUriComplete: String? = nil, + expiresIn: Int?, interval: Int) { + self.deviceCode = deviceCode + self.userCode = userCode + self.verificationUri = verificationUri + self.verificationUriComplete = verificationUriComplete + self.expiresIn = expiresIn + self.interval = interval + } + + /// Custom initializer for handling different keys sent by different providers (Google) + /// - Parameters: + /// - decoder: the decoder to use + public init(from decoder: any Decoder) throws { + + let container = try decoder.container(keyedBy: CodingKeys.self) + deviceCode = try container.decode(String.self, forKey: .deviceCode) + userCode = try container.decode(String.self, forKey: .userCode) + expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) + interval = try container.decode(Int.self, forKey: .interval) + verificationUriComplete = try container.decodeIfPresent(String.self, forKey: .verificationUriComplete) + + let verification = try container.decodeIfPresent(String.self, forKey: .verificationUri) + if let verification { + verificationUri = verification + } else { + verificationUri = try container.decode(String.self, forKey: .verificationUrl) + } + } + + /// Encodes the device code. + /// - Parameters: + /// - encoder: the encoder to use + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(deviceCode, forKey: .deviceCode) + try container.encode(userCode, forKey: .userCode) + try container.encode(verificationUri, forKey: .verificationUri) + try container.encodeIfPresent(verificationUriComplete, forKey: .verificationUri) + try container.encode(interval, forKey: .interval) + try container.encodeIfPresent(expiresIn, forKey: .expiresIn) + } + } +} diff --git a/Sources/OAuthKit/OAuth+GrantType.swift b/Sources/OAuthKit/OAuth+GrantType.swift new file mode 100644 index 0000000..e49dcc1 --- /dev/null +++ b/Sources/OAuthKit/OAuth+GrantType.swift @@ -0,0 +1,22 @@ +// +// OAuth+GrantType.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// Provides an enum representation for the OAuth 2.0 Grant Types. + /// + /// See: https://oauth.net/2/grant-types/ + public enum GrantType: String, Codable, Sendable { + case authorizationCode + case clientCredentials = "client_credentials" + case deviceCode = "device_code" + case pkce + case refreshToken = "refresh_token" + } +} diff --git a/Sources/OAuthKit/OAuth+Provider.swift b/Sources/OAuthKit/OAuth+Provider.swift new file mode 100644 index 0000000..ff5e22f --- /dev/null +++ b/Sources/OAuthKit/OAuth+Provider.swift @@ -0,0 +1,122 @@ +// +// OAuth+Provider.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// Provides configuration data for an OAuth service provider. + public struct Provider: Codable, Identifiable, Hashable, Sendable { + + /// The provider unique id. + public var id: String + /// The provider icon. + public var icon: URL? + /// The provider authorization url. + var authorizationURL: URL + /// The provider access token url. + var accessTokenURL: URL + /// The provider device code url that can be used for devices without browsers (like tvOS). + var deviceCodeURL: URL? + /// The unique client identifier tforinteracting with this providers oauth server. + var clientID: String + /// The client's secret known only to the client and the providers oauth server. It is essential the client's password. + var clientSecret: String + /// The provider redirect uri. + var redirectURI: String? + /// The provider scopes. + var scope: [String]? + /// Informs the oauth client to encode the access token query parameters into the + /// http body (using application/x-www-form-urlencoded) or simply send the query parameters with the request. + /// This is turned on by default, but you may need to disable this based on how the provider is implemented. + var encodeHttpBody: Bool + /// The custom user agent to send with browser requests. Providers such as Slack will block unsupported browsers + /// from initiating oauth workflows. Setting this value to a supported user agent string can allow for workarounds. + /// Be very careful when setting this value as it can have unintended consquences of how servers respond to requests. + var customUserAgent: String? + + /// The coding keys. + enum CodingKeys: String, CodingKey { + case id + case icon + case authorizationURL + case accessTokenURL + case deviceCodeURL + case clientID + case clientSecret + case redirectURI + case scope + case encodeHttpBody + case customUserAgent + } + + /// Custom decoder initializer. + /// - Parameters: + /// - decoder: the decoder to use + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + icon = try container.decodeIfPresent(URL.self, forKey: .icon) + authorizationURL = try container.decode(URL.self, forKey: .authorizationURL) + accessTokenURL = try container.decode(URL.self, forKey: .accessTokenURL) + deviceCodeURL = try container.decodeIfPresent(URL.self, forKey: .deviceCodeURL) + clientID = try container.decode(String.self, forKey: .clientID) + clientSecret = try container.decode(String.self, forKey: .clientSecret) + redirectURI = try container.decodeIfPresent(String.self, forKey: .redirectURI) + scope = try container.decodeIfPresent([String].self, forKey: .scope) + encodeHttpBody = try container.decodeIfPresent(Bool.self, forKey: .encodeHttpBody) ?? true + customUserAgent = try container.decodeIfPresent(String.self, forKey: .customUserAgent) + } + + /// Builds an url request for the specified grant type. + /// - Parameters: + /// - grantType: the grant type to build a request for + /// - token: the current access token + /// - Returns: an url request or nil + public func request(grantType: GrantType, token: Token? = nil) -> URLRequest? { + + var urlComponents = URLComponents() + var queryItems = [URLQueryItem]() + + switch grantType { + case .authorizationCode: + guard let components = URLComponents(string: authorizationURL.absoluteString) else { + return nil + } + urlComponents = components + queryItems.append(URLQueryItem(name: "client_id", value: clientID)) + queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectURI)) + queryItems.append(URLQueryItem(name: "response_type", value: "code")) + if let scope { + queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) + } + case .deviceCode: + guard let deviceCodeURL, let components = URLComponents(string: deviceCodeURL.absoluteString) else { + return nil + } + urlComponents = components + queryItems.append(URLQueryItem(name: "client_id", value: clientID)) + if let scope { + queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) + } + case .clientCredentials, .pkce: + fatalError("TODO: Not implemented") + case .refreshToken: + guard let refreshToken = token?.refreshToken, let components = URLComponents(string: authorizationURL.absoluteString) else { + return nil + } + urlComponents = components + queryItems.append(URLQueryItem(name: "client_id", value: clientID)) + queryItems.append(URLQueryItem(name: "grant_type", value: grantType.rawValue)) + queryItems.append(URLQueryItem(name: "refresh_token", value: refreshToken)) + } + urlComponents.queryItems = queryItems + guard let url = urlComponents.url else { return nil } + return URLRequest(url: url) + } + } +} diff --git a/Sources/OAuthKit/OAuth+Token.swift b/Sources/OAuthKit/OAuth+Token.swift new file mode 100644 index 0000000..79b3614 --- /dev/null +++ b/Sources/OAuthKit/OAuth+Token.swift @@ -0,0 +1,40 @@ +// +// OAuth+Token.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// A codable type that holds oauth token information. + /// See: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ + /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + public struct Token: Codable, Equatable, Sendable { + + public let accessToken: String + public let refreshToken: String? + public let expiresIn: Int? + public let state: String? + public let type: String + + public init(accessToken: String, refreshToken: String?, expiresIn: Int?, state: String?, type: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresIn = expiresIn + self.state = state + self.type = type + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case type = "token_type" + case state + } + } + +} diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index ce2497b..eee8fc9 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -49,232 +49,6 @@ public final class OAuth: NSObject { case memory } - /// Provides an enum representation for the OAuth 2.0 Grant Types. - /// - /// See: https://oauth.net/2/grant-types/ - public enum GrantType: String, Codable, Sendable { - case authorizationCode - case clientCredentials = "client_credentials" - case deviceCode = "device_code" - case pkce - case refreshToken = "refresh_token" - } - - /// Provides configuration data for an OAuth service provider. - public struct Provider: Codable, Identifiable, Hashable, Sendable { - - public var id: String - public var icon: URL? - var authorizationURL: URL - var accessTokenURL: URL - var deviceCodeURL: URL? - fileprivate var clientID: String - fileprivate var clientSecret: String - var redirectURI: String? - var scope: [String]? - - /// Builds an url request for the specified grant type. - /// - Parameters: - /// - grantType: the grant type to build a request for - /// - token: the current access token - /// - Returns: an url request or nil - public func request(grantType: GrantType, token: Token? = nil) -> URLRequest? { - - var urlComponents = URLComponents() - var queryItems = [URLQueryItem]() - - switch grantType { - case .authorizationCode: - guard let components = URLComponents(string: authorizationURL.absoluteString) else { - return nil - } - urlComponents = components - queryItems.append(URLQueryItem(name: "client_id", value: clientID)) - queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectURI)) - queryItems.append(URLQueryItem(name: "response_type", value: "code")) - if let scope { - queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) - } - case .deviceCode: - guard let deviceCodeURL, let components = URLComponents(string: deviceCodeURL.absoluteString) else { - return nil - } - urlComponents = components - queryItems.append(URLQueryItem(name: "client_id", value: clientID)) - if let scope { - queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) - } - case .clientCredentials, .pkce: - fatalError("TODO: Not implemented") - case .refreshToken: - guard let refreshToken = token?.refreshToken, let components = URLComponents(string: authorizationURL.absoluteString) else { - return nil - } - urlComponents = components - queryItems.append(URLQueryItem(name: "client_id", value: clientID)) - queryItems.append(URLQueryItem(name: "grant_type", value: grantType.rawValue)) - queryItems.append(URLQueryItem(name: "refresh_token", value: refreshToken)) - } - urlComponents.queryItems = queryItems - guard let url = urlComponents.url else { return nil } - return URLRequest(url: url) - } - } - - /// A codable type that holds oauth token information. - /// See: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/ - /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 - public struct Token: Codable, Equatable, Sendable { - - public let accessToken: String - public let refreshToken: String? - public let expiresIn: Int? - public let state: String? - public let type: String - - public init(accessToken: String, refreshToken: String?, expiresIn: Int?, state: String?, type: String) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.expiresIn = expiresIn - self.state = state - self.type = type - } - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - case type = "token_type" - case state - } - } - - /// A codable type that holds device code information. - /// See: https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow - /// See: https://www.oauth.com/playground/device-code.html - public struct DeviceCode: Codable, Equatable, Sendable { - - /// The server assigned device code. - public let deviceCode: String - /// The code the user should enter when visiting the `verificationUri` - public let userCode: String - /// The uri the user should visit to enter the `userCode` - public let verificationUri: String - /// Either a QR Code or shortened URL with embedded user code - public let verificationUriComplete: String? - /// The lifetime in seconds for `deviceCode` and `userCode` - public let expiresIn: Int? - /// The polling interval - public let interval: Int - /// The issue date. - public let issued: Date = .now - - /// Returns true if the device code is expired. - public var isExpired: Bool { - guard let expiresIn = expiresIn else { return false } - return issued.addingTimeInterval(Double(expiresIn)) < Date.now - } - - /// Returns the expiration date of the device token or nil if none exists. - public var expiration: Date? { - guard let expiresIn = expiresIn else { return nil } - return issued.addingTimeInterval(TimeInterval(expiresIn)) - } - - enum CodingKeys: String, CodingKey { - case deviceCode = "device_code" - case userCode = "user_code" - case verificationUri = "verification_uri" - /// Google sends `verification_url` instead of `verification_uri` so we need to account for both. - /// See: https://developers.google.com/identity/protocols/oauth2/limited-input-device - case verificationUrl = "verification_url" - case verificationUriComplete = "verification_uri_complete" - case expiresIn = "expires_in" - case interval - } - - /// Public initializer - /// - Parameters: - /// - deviceCode: the device code - /// - userCode: the user code - /// - verificationUri: the verification uri - /// - verificationUriComplete: the qr code or shortened url with embedded user code - /// - expiresIn: lifetime in seconds - /// - interval: the polling interval - public init(deviceCode: String, userCode: String, - verificationUri: String, verificationUriComplete: String? = nil, - expiresIn: Int?, interval: Int) { - self.deviceCode = deviceCode - self.userCode = userCode - self.verificationUri = verificationUri - self.verificationUriComplete = verificationUriComplete - self.expiresIn = expiresIn - self.interval = interval - } - - /// Custom initializer for handling different keys sent by different providers (Google) - public init(from decoder: any Decoder) throws { - - let container = try decoder.container(keyedBy: CodingKeys.self) - deviceCode = try container.decode(String.self, forKey: .deviceCode) - userCode = try container.decode(String.self, forKey: .userCode) - expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) - interval = try container.decode(Int.self, forKey: .interval) - verificationUriComplete = try container.decodeIfPresent(String.self, forKey: .verificationUriComplete) - - let verification = try container.decodeIfPresent(String.self, forKey: .verificationUri) - if let verification { - verificationUri = verification - } else { - verificationUri = try container.decode(String.self, forKey: .verificationUrl) - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(deviceCode, forKey: .deviceCode) - try container.encode(userCode, forKey: .userCode) - try container.encode(verificationUri, forKey: .verificationUri) - try container.encodeIfPresent(verificationUriComplete, forKey: .verificationUri) - try container.encode(interval, forKey: .interval) - try container.encodeIfPresent(expiresIn, forKey: .expiresIn) - } - } - - /// A codable type that holds authorization information that can be stored. - public struct Authorization: Codable, Equatable, Sendable { - - /// The provider ID that issued the authorization. - public let issuer: String - /// The issue date. - public let issued: Date - /// The issued access token. - public let token: Token - - /// Initializer - /// - Parameters: - /// - issuer: the provider ID that issued the authorization. - /// - token: the access token - /// - issued: the issued date - public init(issuer: String, token: Token, issued: Date = Date.now) { - self.issuer = issuer - self.token = token - self.issued = issued - } - - /// Returns true if the token is expired. - public var isExpired: Bool { - guard let expiresIn = token.expiresIn else { return false } - return issued.addingTimeInterval(Double(expiresIn)) < Date.now - } - - /// Returns the expiration date of the authorization or nil if none exists. - public var expiration: Date? { - guard let expiresIn = token.expiresIn else { return nil } - return issued.addingTimeInterval(TimeInterval(expiresIn)) - } - } - /// Holds the OAuth state that is published to subscribers via the `state` property publisher. public enum State: Equatable, Sendable { @@ -319,7 +93,7 @@ public final class OAuth: NSObject { /// The url session to use for communicating with providers. @ObservationIgnored private lazy var urlSession: URLSession = { - .init(configuration: .default) + .init(configuration: .ephemeral) }() @ObservationIgnored @@ -363,11 +137,62 @@ public final class OAuth: NSObject { await start() } } +} + +public extension OAuth { + + /// Starts the authorization process for the specified provider. + /// - Parameters: + /// - provider: the provider to begin authorization for + /// - grantType: the grant type to execute + func authorize(provider: Provider, grantType: GrantType = .authorizationCode) { + switch grantType { + case .authorizationCode: + state = .authorizing(provider, grantType) + case .deviceCode: + Task { + await requestDeviceCode(provider: provider) + } + case .clientCredentials, .pkce, .refreshToken: + break + } + } + + /// Requests to exchange a code for an access token. + /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + /// - Parameters: + /// - provider: the provider the access token is being requested from + /// - code: the code to exchange + func requestAccessToken(provider: Provider, code: String) { + Task { + let result = await requestAccessToken(provider: provider, code: code) + switch result { + case .success(let token): + debugPrint("✅ [Received token]", token) + case .failure(let error): + debugPrint("💩 [Error requesting access token]", error) + } + } + } + + /// Removes all tokens and clears the OAuth state + func clear() { + Task { + debugPrint("⚠️ [Clearing oauth state]") + keychain.clear() + publish(state: .empty) + } + } +} + +// MARK: Private + +private extension OAuth { /// Loads providers from the specified bundle. /// - Parameter bundle: the bundle to load the oauth provider configuration information from. /// - Returns: found providers in the specifed bundle or an empty list if not found - private func loadProviders(_ bundle: Bundle) -> [Provider] { + func loadProviders(_ bundle: Bundle) -> [Provider] { guard let url = bundle.url(forResource: defaultResourceName, withExtension: defaultExtension), let data = try? Data(contentsOf: url), let providers = try? decoder.decode([Provider].self, from: data) else { @@ -377,7 +202,7 @@ public final class OAuth: NSObject { } /// Performs post init operations. - private func start() async { + func start() async { // Initialize with custom options if let options { // Use the custom application tag @@ -390,7 +215,7 @@ public final class OAuth: NSObject { } /// Restores state from storage. - private func restore() async { + func restore() async { for provider in providers { if let authorization: OAuth.Authorization = try? keychain.get(key: provider.id), !authorization.isExpired { publish(state: .authorized(authorization)) @@ -405,26 +230,97 @@ public final class OAuth: NSObject { // TODO: Add Handler }.store(in: &subscribers) } -} -public extension OAuth { + /// Publishes state on the main thread. + /// - Parameter state: the new state information to publish out on the main thread. + func publish(state: State) { + switch state { + case .authorized(let auth): + schedule(auth: auth) + case .receivedDeviceCode(let provider, let deviceCode): + schedule(provider: provider, deviceCode: deviceCode) + case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode: + break + } + self.state = state + } - /// Starts the authorization process for the specified provider. + /// Schedules the provider to be polled for authorization with the specified device token. /// - Parameters: - /// - provider: the provider to begin authorization for - /// - grantType: the grant type to execute - func authorize(provider: Provider, grantType: GrantType = .authorizationCode) { - switch grantType { - case .authorizationCode: - state = .authorizing(provider, grantType) - case .deviceCode: - Task { - await requestDeviceCode(provider: provider) + /// - provider: the oauth provider + /// - deviceCode: the device code issued by the provider + func schedule(provider: Provider, deviceCode: DeviceCode) { + let timeInterval: TimeInterval = .init(deviceCode.interval) + let task = Task.delayed(timeInterval: timeInterval) { [weak self] in + guard let self else { return } + await self.poll(provider: provider, deviceCode: deviceCode) + } + tasks.append(task) + } + + /// Schedules refresh tasks for the specified authorization. + /// - Parameter auth: the authentication to schedule a future tasks for + func schedule(auth: Authorization) { + if let options, let autoRefresh = options[.autoRefresh] as? Bool, autoRefresh { + if let expiration = auth.expiration { + let timeInterval = expiration - Date.now + if timeInterval > 0 { + // Schedule the refresh task + let task = Task.delayed(timeInterval: timeInterval) { [weak self] in + guard let self else { return } + await self.refresh() + } + tasks.append(task) + } else { + // Execute the task immediately + Task { + await refresh() + } + } } - case .clientCredentials, .pkce, .refreshToken: - break } } +} + +// MARK: URLRequests + +fileprivate extension OAuth { + + /// Builds the access token request. This method will either encode the query items into the + /// http body (using application/x-www-form-urlencoded) or simply send the query item parameters with the request + /// based on how the provider is implemented. If you are seeing errors when fetching access tokens from a provider, it may be necessary to + /// disable the `encodeHttpBody` parameter to false as server implementaitons across providers varies. + /// - Parameters: + /// - provider: the provider + /// - code: the code to exchange + /// - Returns: an url request for exchanging a code for an access token. + func buildAccessTokenRequest(provider: Provider, code: String) -> URLRequest? { + let queryItems: [URLQueryItem] = [ + URLQueryItem(name: "client_id", value: provider.clientID), + URLQueryItem(name: "client_secret", value: provider.clientSecret), + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "redirect_uri", value: provider.redirectURI), + URLQueryItem(name: "grant_type", value: "authorization_code") + ] + + guard var urlComponents = URLComponents(string: provider.accessTokenURL.absoluteString) else { return nil } + urlComponents.queryItems = queryItems + guard var url = urlComponents.url else { return nil } + + // If we're encoding the http body, rebuild the url without the query items + if provider.encodeHttpBody { + urlComponents = URLComponents() + urlComponents.queryItems = queryItems + url = provider.accessTokenURL + } + + var request = URLRequest(url: url) + request.httpBody = provider.encodeHttpBody ? urlComponents.query?.data(using: .utf8) : nil + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + return request + } /// Requests to exchange a code for an access token. /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 @@ -437,30 +333,17 @@ public extension OAuth { // Publish the state publish(state: .requestingAccessToken(provider)) - guard let url = URL(string: provider.accessTokenURL.absoluteString) else { + guard let request = buildAccessTokenRequest(provider: provider, code: code) else { publish(state: .empty) return .failure(.malformedURL) } - - var urlComponents = URLComponents() - urlComponents.queryItems = [ - URLQueryItem(name: "client_id", value: provider.clientID), - URLQueryItem(name: "client_secret", value: provider.clientSecret), - URLQueryItem(name: "code", value: code), - URLQueryItem(name: "redirect_uri", value: provider.redirectURI), - URLQueryItem(name: "grant_type", value: "authorization_code") - ] - - // Encode the url components as 'application/x-www-form-urlencoded' body - var request = URLRequest(url: url) - request.httpBody = urlComponents.query?.data(using: .utf8) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") guard let (data, _) = try? await urlSession.data(for: request) else { publish(state: .empty) return .failure(.badResponse) } + debugPrint("⭐️ Raw access token response", String(data: data, encoding: .utf8) ?? "") + // Decode the token guard let token = try? decoder.decode(Token.self, from: data) else { publish(state: .empty) @@ -478,57 +361,9 @@ public extension OAuth { return .success(token) } - /// Requests a device code from the specified provider. - /// - Parameters: - /// - provider: the provider the device code is being requested from - private func requestDeviceCode(provider: Provider) async { - // Publish the state - publish(state: .requestingDeviceCode(provider)) - guard var request = provider.request(grantType: .deviceCode) else { return } - - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - guard let (data, _) = try? await urlSession.data(for: request) else { - publish(state: .empty) - return - } - - // Decode the device code - guard let deviceCode = try? decoder.decode(DeviceCode.self, from: data) else { - publish(state: .empty) - return - } - - // Publish the state - publish(state: .receivedDeviceCode(provider, deviceCode)) - } - - /// Removes all tokens and clears the OAuth state - func clear() { - Task { - debugPrint("⚠️ [Clearing oauth state]") - keychain.clear() - publish(state: .empty) - } - } - - /// Publishes state on the main thread. - /// - Parameter state: the new state information to publish out on the main thread. - private func publish(state: State) { - switch state { - case .authorized(let auth): - schedule(auth: auth) - case .receivedDeviceCode(let provider, let deviceCode): - schedule(provider: provider, deviceCode: deviceCode) - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode: - break - } - self.state = state - } - /// Attempts to refresh the current access token. /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-6 - private func refresh() async { + func refresh() async { switch state { case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: return @@ -563,6 +398,31 @@ public extension OAuth { } } + /// Requests a device code from the specified provider. + /// - Parameters: + /// - provider: the provider the device code is being requested from + private func requestDeviceCode(provider: Provider) async { + // Publish the state + publish(state: .requestingDeviceCode(provider)) + guard var request = provider.request(grantType: .deviceCode) else { return } + + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + guard let (data, _) = try? await urlSession.data(for: request) else { + publish(state: .empty) + return + } + + // Decode the device code + guard let deviceCode = try? decoder.decode(DeviceCode.self, from: data) else { + publish(state: .empty) + return + } + + // Publish the state + publish(state: .receivedDeviceCode(provider, deviceCode)) + } + /// Polls the oauth provider's access token endpoint until the device code has expired or we've successfully received an auth token. /// See: https://oauth.net/2/grant-types/device-code/ /// - Parameters: @@ -578,7 +438,7 @@ public extension OAuth { var queryItems = [URLQueryItem]() queryItems.append(URLQueryItem(name: "client_id", value: provider.clientID)) queryItems.append(URLQueryItem(name: "client_secret", value: provider.clientSecret)) - queryItems.append(URLQueryItem(name: "grant_type", value: "urn:ietf:params:oauth:grant-type:device_code")) + queryItems.append(URLQueryItem(name: "grant_type", value: DeviceCode.grantType)) queryItems.append(URLQueryItem(name: "device_code", value: deviceCode.deviceCode)) urlComponents.queryItems = queryItems guard let url = urlComponents.url else { @@ -607,44 +467,9 @@ public extension OAuth { } publish(state: .authorized(authorization)) } - - /// Schedules the provider to be polled for authorization with the specified device token. - /// - Parameters: - /// - provider: the oauth provider - /// - deviceCode: the device code issued by the provider - private func schedule(provider: Provider, deviceCode: DeviceCode) { - let timeInterval: TimeInterval = .init(deviceCode.interval) - let task = Task.delayed(timeInterval: timeInterval) { [weak self] in - guard let self else { return } - await self.poll(provider: provider, deviceCode: deviceCode) - } - tasks.append(task) - } - - /// Schedules refresh tasks for the specified authorization. - /// - Parameter auth: the authentication to schedule a future tasks for - private func schedule(auth: Authorization) { - if let options, let autoRefresh = options[.autoRefresh] as? Bool, autoRefresh { - if let expiration = auth.expiration { - let timeInterval = expiration - Date.now - if timeInterval > 0 { - // Schedule the refresh task - let task = Task.delayed(timeInterval: timeInterval) { [weak self] in - guard let self else { return } - await self.refresh() - } - tasks.append(task) - } else { - // Execute the task immediately - Task { - await refresh() - } - } - } - } - } } + // MARK: Options public extension OAuth.Option { diff --git a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift index 3ca579f..face36a 100644 --- a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift +++ b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift @@ -39,18 +39,10 @@ public class OAWebViewCoordinator: NSObject { let queryItems = urlComponents?.queryItems ?? [] guard queryItems.isNotEmpty else { return } guard let code = queryItems.filter({ $0.name == "code"}).first?.value else { return } - debugPrint("🚩", url.absoluteString) + debugPrint("Handling url candidate [\(url.absoluteString)], [\(code)]") // If the url begins with the provider redirectURI and a code // has been sent to it then attempt to exchange the code for an an access token - Task { - let result = await oauth.requestAccessToken(provider: provider, code: code) - switch result { - case .success(let token): - debugPrint("✅ [Received token]", token) - case .failure(let error): - debugPrint("💩 [Error requesting access token]", error) - } - } + oauth.requestAccessToken(provider: provider, code: code) } /// Handles oauth state changes. @@ -60,9 +52,13 @@ public class OAWebViewCoordinator: NSObject { case .empty, .authorized, .requestingAccessToken, .requestingDeviceCode: break case .authorizing(let provider, let grantType): + // Override the custom user agent for the provider and tell the browser to load the request + webView.view.customUserAgent = provider.customUserAgent guard let request = provider.request(grantType: grantType) else { return } webView.view.load(request) - case .receivedDeviceCode(_, let deviceCode): + case .receivedDeviceCode(let provider, let deviceCode): + // Override the custom user agent for the provider and tell the browser to load the request + webView.view.customUserAgent = provider.customUserAgent guard let url = URL(string: deviceCode.verificationUri) else { return } let request = URLRequest(url: url) webView.view.load(request) diff --git a/Tests/OAuthKitTests/CodableTests.swift b/Tests/OAuthKitTests/CodableTests.swift index d5d0990..5eda7a7 100644 --- a/Tests/OAuthKitTests/CodableTests.swift +++ b/Tests/OAuthKitTests/CodableTests.swift @@ -40,5 +40,4 @@ struct CodableTests { #expect(deviceCode.interval == decoded.interval) } - } diff --git a/Tests/OAuthKitTests/Resources/oauth.json b/Tests/OAuthKitTests/Resources/oauth.json index e24ff32..87a0756 100644 --- a/Tests/OAuthKitTests/Resources/oauth.json +++ b/Tests/OAuthKitTests/Resources/oauth.json @@ -38,6 +38,31 @@ "profile", "openid" ] + }, + { + "id": "Slack", + "authorizationURL": "https://slack.com/oauth/v2/authorize", + "accessTokenURL": "https://slack.com/api/oauth.v2.access", + "clientID": "CLIENT_ID", + "clientSecret": "CLIENT_SECRET", + "redirectURI": "https://github.com/codefiesta/", + "customUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", + "scope": [ + "incoming-webhook" + ] + }, + { + "id": "LinkedIn", + "authorizationURL": "https://www.linkedin.com/oauth/v2/authorization", + "accessTokenURL": "https://www.linkedin.com/oauth/v2/accessToken", + "clientID": "CLIENT_ID", + "clientSecret": "CLIENT_SECRET", + "redirectURI": "https://www.linkedin.com/developers/tools/oauth/redirect", + "encodeHttpBody": false, + "scope": [ + "email", + "profile", + "openid" + ] } - ] From cecbf2d6d882aa986c350f0840b817977ee2baa5 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Sat, 31 May 2025 09:42:12 -0700 Subject: [PATCH 2/2] Updating README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8de062b..a0a1cb5 100644 --- a/README.md +++ b/README.md @@ -135,9 +135,12 @@ Although OAuthKit will automatically try to load the `oauth.json` file found ins * [Github](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) * [Google](https://developers.google.com/identity/protocols/oauth2) * **Important**: When creating a Google OAuth2 application from the [Google API Console](https://console.developers.google.com/) create an OAuth 2.0 Client type of Web Application (not iOS). +* [LinkedIn](https://developer.linkedin.com/) + * **Important**: When creating a LinkedIn OAuth2 provider, you will need to explicitly set the `encodeHttpBody` property to false otherwise the /token request will fail. Unfortunately, OAuth providers vary in the way they decode the parameters of that request (either encoded into the httpBody or as query parameters). See sample [oauth.json](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). * [Instagram](https://developers.facebook.com/docs/instagram-basic-display-api/guides/getting-access-tokens-and-permissions) * [Microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) * **Important**: When registering an application inside the [Microsoft Azure Portal](https://portal.azure.com/) it's important to choose a **Redirect URI** as **Web** otherwise the `/token` endpoint will return an error when sending the `client_secret` in the body payload. * [Slack](https://api.slack.com/authentication/oauth-v2) + * **Important**: Slack will block unknown browsers from initiating OAuth workflows. See sample [oauth.json](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json) for setting the `customUserAgent` as a workaround. * [Twitter](https://developer.x.com/en/docs/authentication/oauth-2-0) * **Unsupported**: Although OAuthKit *should* work with Twitter/X OAuth2 APIs without any modification, **@codefiesta** has chosen not to support any [Elon Musk](https://www.natesilver.net/p/elon-musk-polls-popularity-nate-silver-bulletin) backed ventures due to his facist, racist, and divisive behavior that epitomizes out-of-touch wealth and greed. **@codefiesta** will not raise objections to other developers who wish to contribute to OAuthKit in order to support Twitter OAuth2.