Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions Sources/OAuthKit/Extensions/URLRequest+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import Foundation

private let bearer = "Bearer"
private let authHeader = "Authorization"

public extension URLRequest {
Expand All @@ -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]")
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/OAuthKit/OAuth+State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
99 changes: 57 additions & 42 deletions Sources/OAuthKit/OAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,9 @@ public extension OAuth {
await requestClientCredentials(provider: provider)
}
case .refreshToken:
break
Task {
await refreshToken(provider: provider)
}
}
}

Expand Down Expand Up @@ -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))
}
}
}
Expand All @@ -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:
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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))
}
}

Expand Down
3 changes: 2 additions & 1 deletion Tests/OAuthKitTests/Resources/oauth.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"scope": [
"user",
"repo"
]
],
"debug": true
},
{
"id": "Google",
Expand Down