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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 46 additions & 0 deletions Sources/OAuthKit/OAuth+Authorization.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}

}
111 changes: 111 additions & 0 deletions Sources/OAuthKit/OAuth+DeviceCode.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
22 changes: 22 additions & 0 deletions Sources/OAuthKit/OAuth+GrantType.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
122 changes: 122 additions & 0 deletions Sources/OAuthKit/OAuth+Provider.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
40 changes: 40 additions & 0 deletions Sources/OAuthKit/OAuth+Token.swift
Original file line number Diff line number Diff line change
@@ -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
}
}

}
Loading