Skip to content

Commit 9f0eacb

Browse files
authored
Encoding + User Agent Options (#40)
1 parent 638e6b4 commit 9f0eacb

File tree

10 files changed

+548
-359
lines changed

10 files changed

+548
-359
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,12 @@ Although OAuthKit will automatically try to load the `oauth.json` file found ins
135135
* [Github](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)
136136
* [Google](https://developers.google.com/identity/protocols/oauth2)
137137
* **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).
138+
* [LinkedIn](https://developer.linkedin.com/)
139+
* **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).
138140
* [Instagram](https://developers.facebook.com/docs/instagram-basic-display-api/guides/getting-access-tokens-and-permissions)
139141
* [Microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
140142
* **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.
141143
* [Slack](https://api.slack.com/authentication/oauth-v2)
144+
* **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.
142145
* [Twitter](https://developer.x.com/en/docs/authentication/oauth-2-0)
143146
* **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.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//
2+
// OAuth+Authorization.swift
3+
// OAuthKit
4+
//
5+
// Created by Kevin McKee
6+
//
7+
8+
import Foundation
9+
10+
extension OAuth {
11+
12+
/// A codable type that holds authorization information that can be stored.
13+
public struct Authorization: Codable, Equatable, Sendable {
14+
15+
/// The provider ID that issued the authorization.
16+
public let issuer: String
17+
/// The issue date.
18+
public let issued: Date
19+
/// The issued access token.
20+
public let token: Token
21+
22+
/// Initializer
23+
/// - Parameters:
24+
/// - issuer: the provider ID that issued the authorization.
25+
/// - token: the access token
26+
/// - issued: the issued date
27+
public init(issuer: String, token: Token, issued: Date = Date.now) {
28+
self.issuer = issuer
29+
self.token = token
30+
self.issued = issued
31+
}
32+
33+
/// Returns true if the token is expired.
34+
public var isExpired: Bool {
35+
guard let expiresIn = token.expiresIn else { return false }
36+
return issued.addingTimeInterval(Double(expiresIn)) < Date.now
37+
}
38+
39+
/// Returns the expiration date of the authorization or nil if none exists.
40+
public var expiration: Date? {
41+
guard let expiresIn = token.expiresIn else { return nil }
42+
return issued.addingTimeInterval(TimeInterval(expiresIn))
43+
}
44+
}
45+
46+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// OAuth+DeviceCode.swift
3+
// OAuthKit
4+
//
5+
// Created by Kevin McKee
6+
//
7+
8+
import Foundation
9+
10+
extension OAuth {
11+
12+
/// A codable type that holds device code information.
13+
/// See: https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow
14+
/// See: https://www.oauth.com/playground/device-code.html
15+
public struct DeviceCode: Codable, Equatable, Sendable {
16+
17+
/// A constant for the oauth grant type.
18+
static let grantType = "urn:ietf:params:oauth:grant-type:device_code"
19+
20+
/// The server assigned device code.
21+
public let deviceCode: String
22+
/// The code the user should enter when visiting the `verificationUri`
23+
public let userCode: String
24+
/// The uri the user should visit to enter the `userCode`
25+
public let verificationUri: String
26+
/// Either a QR Code or shortened URL with embedded user code
27+
public let verificationUriComplete: String?
28+
/// The lifetime in seconds for `deviceCode` and `userCode`
29+
public let expiresIn: Int?
30+
/// The polling interval
31+
public let interval: Int
32+
/// The issue date.
33+
public let issued: Date = .now
34+
35+
/// Returns true if the device code is expired.
36+
public var isExpired: Bool {
37+
guard let expiresIn = expiresIn else { return false }
38+
return issued.addingTimeInterval(Double(expiresIn)) < Date.now
39+
}
40+
41+
/// Returns the expiration date of the device token or nil if none exists.
42+
public var expiration: Date? {
43+
guard let expiresIn = expiresIn else { return nil }
44+
return issued.addingTimeInterval(TimeInterval(expiresIn))
45+
}
46+
47+
enum CodingKeys: String, CodingKey {
48+
case deviceCode = "device_code"
49+
case userCode = "user_code"
50+
case verificationUri = "verification_uri"
51+
/// Google sends `verification_url` instead of `verification_uri` so we need to account for both.
52+
/// See: https://developers.google.com/identity/protocols/oauth2/limited-input-device
53+
case verificationUrl = "verification_url"
54+
case verificationUriComplete = "verification_uri_complete"
55+
case expiresIn = "expires_in"
56+
case interval
57+
}
58+
59+
/// Public initializer
60+
/// - Parameters:
61+
/// - deviceCode: the device code
62+
/// - userCode: the user code
63+
/// - verificationUri: the verification uri
64+
/// - verificationUriComplete: the qr code or shortened url with embedded user code
65+
/// - expiresIn: lifetime in seconds
66+
/// - interval: the polling interval
67+
public init(deviceCode: String, userCode: String,
68+
verificationUri: String, verificationUriComplete: String? = nil,
69+
expiresIn: Int?, interval: Int) {
70+
self.deviceCode = deviceCode
71+
self.userCode = userCode
72+
self.verificationUri = verificationUri
73+
self.verificationUriComplete = verificationUriComplete
74+
self.expiresIn = expiresIn
75+
self.interval = interval
76+
}
77+
78+
/// Custom initializer for handling different keys sent by different providers (Google)
79+
/// - Parameters:
80+
/// - decoder: the decoder to use
81+
public init(from decoder: any Decoder) throws {
82+
83+
let container = try decoder.container(keyedBy: CodingKeys.self)
84+
deviceCode = try container.decode(String.self, forKey: .deviceCode)
85+
userCode = try container.decode(String.self, forKey: .userCode)
86+
expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn)
87+
interval = try container.decode(Int.self, forKey: .interval)
88+
verificationUriComplete = try container.decodeIfPresent(String.self, forKey: .verificationUriComplete)
89+
90+
let verification = try container.decodeIfPresent(String.self, forKey: .verificationUri)
91+
if let verification {
92+
verificationUri = verification
93+
} else {
94+
verificationUri = try container.decode(String.self, forKey: .verificationUrl)
95+
}
96+
}
97+
98+
/// Encodes the device code.
99+
/// - Parameters:
100+
/// - encoder: the encoder to use
101+
public func encode(to encoder: any Encoder) throws {
102+
var container = encoder.container(keyedBy: CodingKeys.self)
103+
try container.encode(deviceCode, forKey: .deviceCode)
104+
try container.encode(userCode, forKey: .userCode)
105+
try container.encode(verificationUri, forKey: .verificationUri)
106+
try container.encodeIfPresent(verificationUriComplete, forKey: .verificationUri)
107+
try container.encode(interval, forKey: .interval)
108+
try container.encodeIfPresent(expiresIn, forKey: .expiresIn)
109+
}
110+
}
111+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//
2+
// OAuth+GrantType.swift
3+
// OAuthKit
4+
//
5+
// Created by Kevin McKee
6+
//
7+
8+
import Foundation
9+
10+
extension OAuth {
11+
12+
/// Provides an enum representation for the OAuth 2.0 Grant Types.
13+
///
14+
/// See: https://oauth.net/2/grant-types/
15+
public enum GrantType: String, Codable, Sendable {
16+
case authorizationCode
17+
case clientCredentials = "client_credentials"
18+
case deviceCode = "device_code"
19+
case pkce
20+
case refreshToken = "refresh_token"
21+
}
22+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//
2+
// OAuth+Provider.swift
3+
// OAuthKit
4+
//
5+
// Created by Kevin McKee
6+
//
7+
8+
import Foundation
9+
10+
extension OAuth {
11+
12+
/// Provides configuration data for an OAuth service provider.
13+
public struct Provider: Codable, Identifiable, Hashable, Sendable {
14+
15+
/// The provider unique id.
16+
public var id: String
17+
/// The provider icon.
18+
public var icon: URL?
19+
/// The provider authorization url.
20+
var authorizationURL: URL
21+
/// The provider access token url.
22+
var accessTokenURL: URL
23+
/// The provider device code url that can be used for devices without browsers (like tvOS).
24+
var deviceCodeURL: URL?
25+
/// The unique client identifier tforinteracting with this providers oauth server.
26+
var clientID: String
27+
/// The client's secret known only to the client and the providers oauth server. It is essential the client's password.
28+
var clientSecret: String
29+
/// The provider redirect uri.
30+
var redirectURI: String?
31+
/// The provider scopes.
32+
var scope: [String]?
33+
/// Informs the oauth client to encode the access token query parameters into the
34+
/// http body (using application/x-www-form-urlencoded) or simply send the query parameters with the request.
35+
/// This is turned on by default, but you may need to disable this based on how the provider is implemented.
36+
var encodeHttpBody: Bool
37+
/// The custom user agent to send with browser requests. Providers such as Slack will block unsupported browsers
38+
/// from initiating oauth workflows. Setting this value to a supported user agent string can allow for workarounds.
39+
/// Be very careful when setting this value as it can have unintended consquences of how servers respond to requests.
40+
var customUserAgent: String?
41+
42+
/// The coding keys.
43+
enum CodingKeys: String, CodingKey {
44+
case id
45+
case icon
46+
case authorizationURL
47+
case accessTokenURL
48+
case deviceCodeURL
49+
case clientID
50+
case clientSecret
51+
case redirectURI
52+
case scope
53+
case encodeHttpBody
54+
case customUserAgent
55+
}
56+
57+
/// Custom decoder initializer.
58+
/// - Parameters:
59+
/// - decoder: the decoder to use
60+
public init(from decoder: any Decoder) throws {
61+
let container = try decoder.container(keyedBy: CodingKeys.self)
62+
id = try container.decode(String.self, forKey: .id)
63+
icon = try container.decodeIfPresent(URL.self, forKey: .icon)
64+
authorizationURL = try container.decode(URL.self, forKey: .authorizationURL)
65+
accessTokenURL = try container.decode(URL.self, forKey: .accessTokenURL)
66+
deviceCodeURL = try container.decodeIfPresent(URL.self, forKey: .deviceCodeURL)
67+
clientID = try container.decode(String.self, forKey: .clientID)
68+
clientSecret = try container.decode(String.self, forKey: .clientSecret)
69+
redirectURI = try container.decodeIfPresent(String.self, forKey: .redirectURI)
70+
scope = try container.decodeIfPresent([String].self, forKey: .scope)
71+
encodeHttpBody = try container.decodeIfPresent(Bool.self, forKey: .encodeHttpBody) ?? true
72+
customUserAgent = try container.decodeIfPresent(String.self, forKey: .customUserAgent)
73+
}
74+
75+
/// Builds an url request for the specified grant type.
76+
/// - Parameters:
77+
/// - grantType: the grant type to build a request for
78+
/// - token: the current access token
79+
/// - Returns: an url request or nil
80+
public func request(grantType: GrantType, token: Token? = nil) -> URLRequest? {
81+
82+
var urlComponents = URLComponents()
83+
var queryItems = [URLQueryItem]()
84+
85+
switch grantType {
86+
case .authorizationCode:
87+
guard let components = URLComponents(string: authorizationURL.absoluteString) else {
88+
return nil
89+
}
90+
urlComponents = components
91+
queryItems.append(URLQueryItem(name: "client_id", value: clientID))
92+
queryItems.append(URLQueryItem(name: "redirect_uri", value: redirectURI))
93+
queryItems.append(URLQueryItem(name: "response_type", value: "code"))
94+
if let scope {
95+
queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " ")))
96+
}
97+
case .deviceCode:
98+
guard let deviceCodeURL, let components = URLComponents(string: deviceCodeURL.absoluteString) else {
99+
return nil
100+
}
101+
urlComponents = components
102+
queryItems.append(URLQueryItem(name: "client_id", value: clientID))
103+
if let scope {
104+
queryItems.append(URLQueryItem(name: "scope", value: scope.joined(separator: " ")))
105+
}
106+
case .clientCredentials, .pkce:
107+
fatalError("TODO: Not implemented")
108+
case .refreshToken:
109+
guard let refreshToken = token?.refreshToken, let components = URLComponents(string: authorizationURL.absoluteString) else {
110+
return nil
111+
}
112+
urlComponents = components
113+
queryItems.append(URLQueryItem(name: "client_id", value: clientID))
114+
queryItems.append(URLQueryItem(name: "grant_type", value: grantType.rawValue))
115+
queryItems.append(URLQueryItem(name: "refresh_token", value: refreshToken))
116+
}
117+
urlComponents.queryItems = queryItems
118+
guard let url = urlComponents.url else { return nil }
119+
return URLRequest(url: url)
120+
}
121+
}
122+
}

Sources/OAuthKit/OAuth+Token.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// OAuth+Token.swift
3+
// OAuthKit
4+
//
5+
// Created by Kevin McKee
6+
//
7+
8+
import Foundation
9+
10+
extension OAuth {
11+
12+
/// A codable type that holds oauth token information.
13+
/// See: https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
14+
/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
15+
public struct Token: Codable, Equatable, Sendable {
16+
17+
public let accessToken: String
18+
public let refreshToken: String?
19+
public let expiresIn: Int?
20+
public let state: String?
21+
public let type: String
22+
23+
public init(accessToken: String, refreshToken: String?, expiresIn: Int?, state: String?, type: String) {
24+
self.accessToken = accessToken
25+
self.refreshToken = refreshToken
26+
self.expiresIn = expiresIn
27+
self.state = state
28+
self.type = type
29+
}
30+
31+
enum CodingKeys: String, CodingKey {
32+
case accessToken = "access_token"
33+
case refreshToken = "refresh_token"
34+
case expiresIn = "expires_in"
35+
case type = "token_type"
36+
case state
37+
}
38+
}
39+
40+
}

0 commit comments

Comments
 (0)