diff --git a/README.md b/README.md index d1c87b3..39abce2 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ struct ContentView: View { 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)]") { + case .authorized(let provider, _): + Button("Authorized [\(provider.id)]") { oauth.clear() } case .receivedDeviceCode(_, let deviceCode): @@ -174,16 +174,22 @@ let grantType: OAuth.GrantType = .clientCredentials oauth.authorize(provider: provider, grantType: grantType) ``` +## OAuth 2.0 Provider Debugging +Standard `debugPrint` to the standard output is disabled by default. If you need to inspect response data received from [providers](https://github.com/codefiesta/OAuthKit/blob/main/Sources/OAuthKit/OAuth+Provider.swift), you can toggle the `debug` value to true. You can see an [example here](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). + +## OAuthKit Sample Application +You can find a sample application integrated with OAuthKit [here](https://github.com/codefiesta/OAuthSample). ## Security Best Practices Although OAuthKit will automatically try to load the `oauth.json` file found inside your main bundle (or bundle passed to the initializer) for convenience purposes, it is good policy to **NEVER** check in **clientID** or **clientSecret** values into source control. Also, it is possible for someone to [inspect and reverse engineer](https://www.nowsecure.com/blog/2021/09/08/basics-of-reverse-engineering-ios-mobile-apps/) the contents of your app and look at any files inside your app bundle which means you could potentially expose these secrets in the `oauth.json` file. The most secure way to protect OAuth secrets is to build your Providers programatically and bake [secret values](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions) into your code via your CI pipeline. -## OAuth Providers +## OAuth 2.0 Providers * [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). + * LinkedIn currently doesn't support **PKCE**. * [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. diff --git a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift index 66adfb0..3e83dd9 100644 --- a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift @@ -7,7 +7,6 @@ import Foundation -private let bearer = "Bearer" private let authHeader = "Authorization" public extension URLRequest { @@ -17,8 +16,8 @@ public extension URLRequest { @MainActor mutating func addAuthorization(oath: OAuth) { switch oath.state { - case .authorized(let auth): - addValue("\(bearer) \(auth.token.accessToken)", forHTTPHeaderField: authHeader) + case .authorized(_, let auth): + addValue("\(auth.token.type) \(auth.token.accessToken)", forHTTPHeaderField: authHeader) case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: debugPrint("⚠️ [OAuth is not authorized]") } diff --git a/Sources/OAuthKit/OAuth+State.swift b/Sources/OAuthKit/OAuth+State.swift index 2e8c761..e7127b2 100644 --- a/Sources/OAuthKit/OAuth+State.swift +++ b/Sources/OAuthKit/OAuth+State.swift @@ -40,7 +40,8 @@ extension OAuth { /// An authorization has been granted. /// - Parameters: + /// - Provider: the oauth provider /// - Authorization: the oauth authorization - case authorized(Authorization) + case authorized(Provider, Authorization) } } diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index d6b39c9..0dcf3a1 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -133,7 +133,9 @@ public extension OAuth { await requestClientCredentials(provider: provider) } case .refreshToken: - break + Task { + await refreshToken(provider: provider) + } } } @@ -202,7 +204,7 @@ private extension OAuth { func restore() async { for provider in providers { if let authorization: OAuth.Authorization = try? keychain.get(key: provider.id), !authorization.isExpired { - publish(state: .authorized(authorization)) + publish(state: .authorized(provider, authorization)) } } } @@ -219,8 +221,8 @@ private extension OAuth { /// - 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 .authorized(let provider, let auth): + schedule(provider: provider, auth: auth) case .receivedDeviceCode(let provider, let deviceCode): schedule(provider: provider, deviceCode: deviceCode) case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode: @@ -243,24 +245,27 @@ private extension OAuth { } /// Schedules refresh tasks for the specified authorization. - /// - Parameter auth: the authentication to schedule a future tasks for - func schedule(auth: Authorization) { + /// - Parameters: + /// - provider: the oauth provider + /// - auth: the authentication to schedule a future tasks for + func schedule(provider: Provider, auth: Authorization) { + // Don't bother scheduling a task for tokens that can't refresh guard let _ = auth.token.refreshToken else { return } - if let options, let autoRefresh = options[.autoRefresh] as? Bool, autoRefresh { + if let options, let autoRefreshEnabled = options[.autoRefresh] as? Bool, autoRefreshEnabled { if let expiration = auth.expiration { let timeInterval = expiration - Date.now if timeInterval > 0 { - // Schedule the refresh task + // Schedule the auto refresh task let task = Task.delayed(timeInterval: timeInterval) { [weak self] in guard let self else { return } - await self.refresh() + await self.refreshToken(provider: provider) } tasks.append(task) } else { // Execute the task immediately Task { - await refresh() + await refreshToken(provider: provider) } } } @@ -297,7 +302,7 @@ fileprivate extension OAuth { if provider.debug { let statusCode = response.statusCode() ?? -1 let rawData = String(data: data, encoding: .utf8) ?? .empty - debugPrint("Status Code: [\(statusCode))] Data: [\(rawData)]") + debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") } // Decode the token @@ -313,43 +318,47 @@ fileprivate extension OAuth { return .failure(.keychain) } - publish(state: .authorized(authorization)) + publish(state: .authorized(provider, authorization)) return .success(token) } - /// Attempts to refresh the current access token. + /// Refreshes the token for the specified provider. /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-6 - func refresh() async { - switch state { - case .empty, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: + /// - Parameters: + /// - provider: the provider to request a refresh token for + func refreshToken(provider: Provider) async { + guard let auth: OAuth.Authorization = try? keychain.get(key: provider.id) else { return - case .authorized(let auth): - guard let provider = providers.filter({ $0.id == auth.issuer }).first else { - return - } + } - // If we can't build a refresh request and the token is expired, simply clear the token and state - guard let request = Request.refresh(provider: provider, token: auth.token) else { - if auth.isExpired { clear() } - return - } + // If we can't build a refresh request simply bail as no refresh token + // was returned in the original auth request + guard let request = Request.refresh(provider: provider, token: auth.token) else { + if auth.isExpired { clear() } + return + } - guard let (data, _) = try? await urlSession.data(for: request) else { - return publish(state: .empty) - } + guard let (data, response) = try? await urlSession.data(for: request) else { + return publish(state: .empty) + } - // Decode the token - guard let token = try? decoder.decode(Token.self, from: data) else { - return publish(state: .empty) - } + if provider.debug { + let statusCode = response.statusCode() ?? -1 + let rawData = String(data: data, encoding: .utf8) ?? .empty + debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") + } - // 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)) + // Decode the token + guard let token = try? decoder.decode(Token.self, from: data) else { + return publish(state: .empty) + } + + // 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(provider, authorization)) } /// Requests a device code from the specified provider. @@ -368,7 +377,7 @@ fileprivate extension OAuth { if provider.debug { let statusCode = response.statusCode() ?? -1 let rawData = String(data: data, encoding: .utf8) ?? .empty - debugPrint("Status Code: [\(statusCode))] Data: [\(rawData)]") + debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") } // Decode the device code @@ -394,7 +403,7 @@ fileprivate extension OAuth { if provider.debug { let statusCode = response.statusCode() ?? -1 let rawData = String(data: data, encoding: .utf8) ?? .empty - debugPrint("Status Code: [\(statusCode))] Data: [\(rawData)]") + debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") } // Decode the token @@ -407,7 +416,7 @@ fileprivate extension OAuth { guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { return publish(state: .empty) } - publish(state: .authorized(authorization)) + publish(state: .authorized(provider, authorization)) } /// Polls the oauth provider's access token endpoint until the device code has expired or we've successfully received an auth token. @@ -427,6 +436,12 @@ fileprivate extension OAuth { return } + if provider.debug { + let statusCode = response.statusCode() ?? -1 + let rawData = String(data: data, encoding: .utf8) ?? .empty + debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") + } + /// 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 @@ -439,7 +454,7 @@ fileprivate extension OAuth { guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { return publish(state: .empty) } - publish(state: .authorized(authorization)) + publish(state: .authorized(provider, authorization)) } } diff --git a/Tests/OAuthKitTests/Resources/oauth.json b/Tests/OAuthKitTests/Resources/oauth.json index 87a0756..d93fad0 100644 --- a/Tests/OAuthKitTests/Resources/oauth.json +++ b/Tests/OAuthKitTests/Resources/oauth.json @@ -10,7 +10,8 @@ "scope": [ "user", "repo" - ] + ], + "debug": true }, { "id": "Google",