diff --git a/README.md b/README.md index ea7b1f6..727da33 100644 --- a/README.md +++ b/README.md @@ -50,17 +50,27 @@ struct ContentView: View { switch oauth.state { case .empty: providerList - case .authorizing(let provider): + case .authorizing(let provider, _): Text("Authorizing [\(provider.id)]") case .requestingAccessToken(let provider): Text("Requesting Access Token [\(provider.id)]") + case .requestingDeviceCode(let provider): + Text("Requesting Device Code [\(provider.id)]") case .authorized(let auth): Button("Authorized [\(auth.provider.id)]") { oauth.clear() } + case .receivedDeviceCode(_, let deviceCode): + Text("To login, visit") + Text(deviceCode.verificationUri).foregroundStyle(.blue) + Text("and enter the following code:") + Text(deviceCode.userCode) + .padding() + .border(Color.primary) + .font(.title) } } - .onChange(of: oauth.state) { state, _ in + .onChange(of: oauth.state) { _, state in handle(state: state) } } @@ -69,8 +79,8 @@ struct ContentView: View { var providerList: some View { List(oauth.providers) { provider in Button(provider.id) { - // Start the authorization flow - oauth.authorize(provider: provider) + // Start the authorization flow (use .deviceCode for tvOS) + oauth.authorize(provider: provider, grantType: .authorizationCode) } } } @@ -79,9 +89,9 @@ struct ContentView: View { /// - Parameter state: the published state change private func handle(state: OAuth.State) { switch state { - case .empty, .requestingAccessToken: + case .empty, .requestingAccessToken, .requestingDeviceCode: break - case .authorizing(let provider): + case .authorizing, .receivedDeviceCode: openWindow(id: "oauth") case .authorized(_): dismissWindow(id: "oauth") @@ -89,6 +99,12 @@ struct ContentView: View { } } ``` +## tvOS (Device Authorization Grant) +OAuthKit supports the [OAuth 2.0 Device Authorization Grant](https://alexbilbie.github.io/2016/04/oauth-2-device-flow-grant/), which is used by apps that don't have access to a web browser (like tvOS). To leverage OAuthKit in tvOS apps, simply add the `deviceCodeURL` to your [OAuth.Provider](https://github.com/codefiesta/OAuthKit/blob/main/Sources/OAuthKit/OAuth.swift#L64) and initialize the device authorization grant workflow by calling ```oauth.authorize(provider: provider, grantType: .deviceCode)``` + +![tvOS-screenshot](https://github.com/user-attachments/assets/14997164-f86a-4ee0-b6b7-8c0d9732c83e) + + ## OAuthKit Configuration By default, the easiest way to configure OAuthKit is to simply drop an `oauth.json` file into your main bundle and it will get automatically loaded into your swift application and available as an [EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject). You can find an example `oauth.json` file [here](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). diff --git a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift index c88532c..66adfb0 100644 --- a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift @@ -19,7 +19,7 @@ public extension URLRequest { switch oath.state { case .authorized(let auth): addValue("\(bearer) \(auth.token.accessToken)", forHTTPHeaderField: authHeader) - case .empty, .authorizing, .requestingAccessToken: + case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: debugPrint("⚠️ [OAuth is not authorized]") } } diff --git a/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift b/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift new file mode 100644 index 0000000..cdc8917 --- /dev/null +++ b/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift @@ -0,0 +1,28 @@ +// +// URLResponse+Extensions.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +public extension URLResponse { + + /// Returns true if the response status code is in the 200's. + var isOK: Bool { + if let code = statusCode() { + return 200...299 ~= code + } + return false + } + + /// Extracts the status code from the response. + func statusCode() -> Int? { + if let httpResponse = self as? HTTPURLResponse { + return httpResponse.statusCode + } + return nil + } +} + diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 58380ca..7ca6741 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -52,7 +52,7 @@ public final class OAuth: NSObject { /// Provides an enum representation for the OAuth 2.0 Grant Types. /// /// See: https://oauth.net/2/grant-types/ - public enum GrantType: String, Codable { + public enum GrantType: String, Codable, Sendable { case authorizationCode case clientCredentials = "client_credentials" case deviceCode = "device_code" @@ -67,6 +67,7 @@ public final class OAuth: NSObject { public var icon: URL? var authorizationURL: URL var accessTokenURL: URL + var deviceCodeURL: URL? fileprivate var clientID: String fileprivate var clientSecret: String var redirectURI: String? @@ -94,7 +95,16 @@ public final class OAuth: NSObject { if let scope { queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " "))) } - case .clientCredentials, .deviceCode, .pkce: + 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 { @@ -139,6 +149,59 @@ public final class OAuth: NSObject { } } + /// 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 + + 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 + } + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationUri = "verification_uri" + case verificationUriComplete = "verification_uri_complete" + case expiresIn = "expires_in" + case interval + } + + /// 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)) + } + } + /// A codable type that holds authorization information that can be stored. public struct Authorization: Codable, Equatable, Sendable { @@ -160,7 +223,7 @@ public final class OAuth: NSObject { self.issued = issued } - /// Returns true if the token is expired or not. + /// 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 @@ -179,16 +242,29 @@ public final class OAuth: NSObject { /// The state is empty and no authorizations or tokens have been issued. case empty - /// The OAuth authorization step has been started for the specifed provider. + /// The OAuth authorization workflow has been started for the specifed provider and grant type. /// - Parameters: /// - Provider: the oauth provider - case authorizing(Provider) + /// - GrantType: the grant type + case authorizing(Provider, GrantType) /// An access token is being requested for the specifed provider. /// - Parameters: /// - Provider: the oauth provider case requestingAccessToken(Provider) + /// A device code is being requested for the specifed provider. + /// - Parameters: + /// - Provider: the oauth provider + case requestingDeviceCode(Provider) + + /// A device code has been received by the specified provider and it's access token endpoint is + /// actively being polled at the device code's interval until it expires, or until an error or access token is returned. + /// - Parameters: + /// - Provider: the oauth provider + /// - DeviceCode: the device code + case receivedDeviceCode(Provider, DeviceCode) + /// An authorization has been granted. /// - Parameters: /// - Authorization: the oauth authorization @@ -220,6 +296,10 @@ public final class OAuth: NSObject { @ObservationIgnored private var subscribers = Set() + /// The json decoder + @ObservationIgnored + private let decoder: JSONDecoder = .init() + /// Initializes the OAuth service with the specified providers. /// - Parameters: /// - providers: the list of oauth providers @@ -251,7 +331,7 @@ public final class OAuth: NSObject { private func loadProviders(_ bundle: Bundle) -> [Provider] { guard let url = bundle.url(forResource: defaultResourceName, withExtension: defaultExtension), let data = try? Data(contentsOf: url), - let providers = try? JSONDecoder().decode([Provider].self, from: data) else { + let providers = try? decoder.decode([Provider].self, from: data) else { return [] } return providers @@ -291,9 +371,20 @@ public final class OAuth: NSObject { public extension OAuth { /// Starts the authorization process for the specified provider. - /// - Parameter provider: the provider to being authorization for - func authorize(provider: Provider) { - state = .authorizing(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. @@ -329,7 +420,6 @@ public extension OAuth { } // Decode the token - let decoder = JSONDecoder() guard let token = try? decoder.decode(Token.self, from: data) else { publish(state: .empty) return .failure(.decoding) @@ -346,6 +436,31 @@ 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 { @@ -355,11 +470,25 @@ public extension OAuth { } } + /// 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 { switch state { - case .empty, .authorizing, .requestingAccessToken: + case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: return case .authorized(let auth): guard let provider = providers.filter({ $0.id == auth.issuer }).first else { @@ -379,7 +508,6 @@ public extension OAuth { } // Decode the token - let decoder = JSONDecoder() guard let token = try? decoder.decode(Token.self, from: data) else { return publish(state: .empty) } @@ -393,16 +521,61 @@ public extension OAuth { } } - /// 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 .empty, .authorizing, .requestingAccessToken: - break + /// 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: + /// - provider: the provider to poll + /// - deviceCode: the device code to use + private func poll(provider: Provider, deviceCode: DeviceCode) async { + + guard !deviceCode.isExpired, var urlComponents = URLComponents(string: provider.accessTokenURL.absoluteString) else { + publish(state: .empty) + return } - self.state = state + + var queryItems = [URLQueryItem]() + queryItems.append(URLQueryItem(name: "client_id", value: provider.clientID)) + queryItems.append(URLQueryItem(name: "grant_type", value: "urn:ietf:params:oauth:grant-type:device_code")) + queryItems.append(URLQueryItem(name: "device_code", value: deviceCode.deviceCode)) + urlComponents.queryItems = queryItems + guard let url = urlComponents.url else { + publish(state: .empty) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + guard let (data, response) = try? await urlSession.data(for: request) else { + publish(state: .empty) + return + } + + /// If we received something other than a 200 response or we can't decode the token then restart the polling + guard response.isOK, let token = try? decoder.decode(Token.self, from: data) else { + // Reschedule the polling task + schedule(provider: provider, deviceCode: deviceCode) + return + } + + // Store the authorization + let authorization = Authorization(issuer: provider.id, token: token) + guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { + return publish(state: .empty) + } + 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. @@ -413,14 +586,15 @@ public extension OAuth { let timeInterval = expiration - Date.now if timeInterval > 0 { // Schedule the refresh task - let task = Task.delayed(timeInterval: timeInterval) { + 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 self.refresh() + await refresh() } } } diff --git a/Sources/OAuthKit/Views/OAWebView.swift b/Sources/OAuthKit/Views/OAWebView.swift index 37e6f81..a3989fb 100644 --- a/Sources/OAuthKit/Views/OAWebView.swift +++ b/Sources/OAuthKit/Views/OAWebView.swift @@ -56,7 +56,6 @@ extension OAWebView: NSViewRepresentable { } public func updateNSView(_ nsView: NSViewType, context: Context) { - debugPrint("✅ [Pushing state]", oauth.state) context.coordinator.update(state: oauth.state) } } diff --git a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift index 260ca11..95fbae6 100644 --- a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift +++ b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift @@ -31,7 +31,9 @@ public class OAWebViewCoordinator: NSObject { /// - Parameters: /// - url: the url to handle /// - provider: the oauth provider - private func handle(url: URL, provider: OAuth.Provider) { + /// - grantType: the grant type to handle + private func handle(url: URL, provider: OAuth.Provider, grantType: OAuth.GrantType) { + guard grantType == .authorizationCode else { return } debugPrint("👻", url.absoluteString) let urlComponents = URLComponents(string: url.absoluteString) if let queryItems = urlComponents?.queryItems { @@ -53,10 +55,14 @@ public class OAWebViewCoordinator: NSObject { /// - Parameter state: the published state change. func update(state: OAuth.State) { switch state { - case .empty, .authorized, .requestingAccessToken: + case .empty, .authorized, .requestingAccessToken, .requestingDeviceCode: break - case .authorizing(let provider): - guard let request = provider.request(grantType: .authorizationCode) else { return } + case .authorizing(let provider, let grantType): + guard let request = provider.request(grantType: grantType) else { return } + webView.view.load(request) + case .receivedDeviceCode(_, let deviceCode): + guard let url = URL(string: deviceCode.verificationUri) else { return } + let request = URLRequest(url: url) webView.view.load(request) } } @@ -69,10 +75,10 @@ extension OAWebViewCoordinator: WKNavigationDelegate { return .cancel } switch oauth.state { - case .empty, .requestingAccessToken, .authorized: + case .empty, .requestingAccessToken, .authorized, .requestingDeviceCode, .receivedDeviceCode: break - case .authorizing(let provider): - handle(url: url, provider: provider) + case .authorizing(let provider, let grantType): + handle(url: url, provider: provider, grantType: grantType) } return .allow } diff --git a/Tests/OAuthKitTests/CodableTests.swift b/Tests/OAuthKitTests/CodableTests.swift index 1b72644..d5d0990 100644 --- a/Tests/OAuthKitTests/CodableTests.swift +++ b/Tests/OAuthKitTests/CodableTests.swift @@ -25,4 +25,20 @@ struct CodableTests { #expect(token == decoded) } + @Test("Encoding and Decoding Device Codes") + func whenDecodingDeviceCodes() async throws { + + let deviceCode: OAuth.DeviceCode = .init(deviceCode: UUID().uuidString, userCode: "ABC-XYZ", verificationUri: "https://example.com/device", expiresIn: 1800, interval: 5) + + let data = try encoder.encode(deviceCode) + let decoded: OAuth.DeviceCode = try decoder.decode(OAuth.DeviceCode.self, from: data) + #expect(deviceCode.deviceCode == decoded.deviceCode) + #expect(deviceCode.userCode == decoded.userCode) + #expect(deviceCode.verificationUri == decoded.verificationUri) + #expect(deviceCode.verificationUriComplete == decoded.verificationUriComplete) + #expect(deviceCode.expiresIn == decoded.expiresIn) + #expect(deviceCode.interval == decoded.interval) + } + + } diff --git a/Tests/OAuthKitTests/Resources/oauth.json b/Tests/OAuthKitTests/Resources/oauth.json index c0be568..4a5f08b 100644 --- a/Tests/OAuthKitTests/Resources/oauth.json +++ b/Tests/OAuthKitTests/Resources/oauth.json @@ -3,6 +3,7 @@ "id": "GitHub", "authorizationURL": "https://github.com/login/oauth/authorize", "accessTokenURL": "https://github.com/login/oauth/access_token", + "deviceCodeURL": "https://github.com/login/device/code", "clientID": "CLIENT_ID", "clientSecret": "CLIENT_SECRET", "redirectURI": "oauthkit://callback",