* fix #203 NetworkTransport race, with main-isolated variables (#206) Co-authored-by: wti <wti@users.noreply.github.com> * feat/auth: Authentication implementation (#205) * feat: added authentication * feat(auth): improvement to error handling * feat(auth): updated errors and tests in OAuthClientRegistrar * feat(auth): updated ci conformance package version * feat(auth): bumped node version * feat(auth): updated ci conformance step * feat(auth): improvement to Linux support * chore: added docc plugin * chore: added spi yml * chore: added documentation step to ci * chore: increased timeout for test from 5 to 10m --------- Co-authored-by: Paal Øye-Strømme <paal.o.eye@gmail.com> Co-authored-by: wti <wti@users.noreply.github.com> Co-authored-by: yehorsobko <yehorsobko@macpaw.com>
90 lines
3.2 KiB
Swift
90 lines
3.2 KiB
Swift
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
/// Internal protocol for making OAuth token endpoint requests.
|
|
protocol OAuthTokenRequesting: Sendable {
|
|
func request(
|
|
parameters: inout [String: String],
|
|
endpoint: URL,
|
|
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
|
session: URLSession
|
|
) async throws -> OAuthTokenResponse
|
|
}
|
|
|
|
/// Stateless OAuth token endpoint HTTP client.
|
|
///
|
|
/// Handles the low-level HTTP mechanics of making token requests.
|
|
struct OAuthTokenEndpointClient: Sendable {
|
|
let urlValidator: OAuthURLValidator
|
|
|
|
init(urlValidator: OAuthURLValidator) {
|
|
self.urlValidator = urlValidator
|
|
}
|
|
|
|
/// Makes a token request to the given endpoint.
|
|
func request(
|
|
parameters: inout [String: String],
|
|
endpoint: URL,
|
|
authentication: OAuthConfiguration.TokenEndpointAuthentication,
|
|
session: URLSession
|
|
) async throws -> OAuthTokenResponse {
|
|
var urlRequest = URLRequest(url: endpoint)
|
|
urlRequest.httpMethod = "POST"
|
|
urlRequest.setValue(ContentType.formURLEncoded, forHTTPHeaderField: HTTPHeaderName.contentType)
|
|
urlRequest.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
|
|
|
try await authentication.apply(
|
|
to: &urlRequest,
|
|
bodyParameters: ¶meters,
|
|
tokenEndpoint: endpoint
|
|
)
|
|
urlRequest.httpBody = encodeForm(parameters)
|
|
|
|
let (data, response) = try await session.data(for: urlRequest)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw OAuthAuthorizationError.tokenRequestFailed(statusCode: -1, oauthError: nil)
|
|
}
|
|
|
|
guard (200..<300).contains(httpResponse.statusCode) else {
|
|
let oauthError =
|
|
(try? JSONDecoder().decode(OAuthTokenErrorResponse.self, from: data))?.error
|
|
throw OAuthAuthorizationError.tokenRequestFailed(
|
|
statusCode: httpResponse.statusCode,
|
|
oauthError: oauthError
|
|
)
|
|
}
|
|
|
|
let decoded = try JSONDecoder().decode(OAuthTokenResponse.self, from: data)
|
|
guard !decoded.accessToken.isEmpty else {
|
|
throw OAuthAuthorizationError.tokenResponseInvalid
|
|
}
|
|
let tokenType = decoded.tokenType.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !tokenType.isEmpty,
|
|
tokenType.caseInsensitiveCompare(OAuthTokenType.bearer) == .orderedSame
|
|
else {
|
|
throw OAuthAuthorizationError.tokenResponseInvalid
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
private func encodeForm(_ params: [String: String]) -> Data {
|
|
let body = params
|
|
.sorted { $0.key < $1.key }
|
|
.map { key, value in "\(percentEncode(key))=\(percentEncode(value))" }
|
|
.joined(separator: "&")
|
|
return Data(body.utf8)
|
|
}
|
|
|
|
private func percentEncode(_ string: String) -> String {
|
|
let allowed = CharacterSet(
|
|
charactersIn:
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
)
|
|
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
|
|
}
|
|
}
|
|
|
|
extension OAuthTokenEndpointClient: OAuthTokenRequesting {}
|