Skip to content

Commit

Permalink
Support generalised OAuth 2.1 clients (#92)
Browse files Browse the repository at this point in the history
* WIP

* WIP

* WIP

* chore: Working Spotify oauth client

* chore: Adds credential hooks

* chore: remove conditional compilation

* feat: Improved OAuth delegate & proxy

* feat: supports for sign in & out via proxy

* chore: restore 5.9 tools version

* chore: fix test imports

* feat: StandardOAuthSystem

* chore: Add unit tests for standard system

* chore: fix package & test

* chore: Add test for OAuth Proxy

* chore: more OAuth tests

* chore: Testing

---------

Co-authored-by: danthorpe <[email protected]>
  • Loading branch information
danthorpe and danthorpe committed Aug 3, 2024
1 parent e507dd0 commit b5dd469
Show file tree
Hide file tree
Showing 42 changed files with 1,555 additions and 208 deletions.
41 changes: 30 additions & 11 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,21 @@ package.platforms = [

// MARK: - 🧸 Module Names

let Networking = "Networking"
let Helpers = "Helpers"
let Networking = "Networking"
let OAuth = "OAuth"
let TestSupport = "TestSupport"

// MARK: - 🔑 Builders

let 📦 = Module.builder(
withDefaults: .init(
name: "Basic Module",
dependsOn: [],
defaultWith: [
.dependencies
.dependencies,
.dependenciesMacros,
.concurrencyExtras,
],
unitTestsDependsOn: [],
swiftSettings: .concurrency
)
)
Expand All @@ -49,9 +50,9 @@ Networking
.algorithms,
.asyncAlgorithms,
.cache,
.concurrencyExtras,
.httpTypes,
.httpTypesFoundation,
.protected,
.shortID,
.tagged,
]
Expand All @@ -64,6 +65,22 @@ Networking
]
}

OAuth
<+ 📦 {
$0.createProduct = .library(nil)
$0.dependsOn = [
Helpers,
Networking,
]
$0.unitTestsDependsOn = [
TestSupport
]
$0.unitTestsWith = [
.assertionExtras,
.concurrencyExtras,
]
}

TestSupport
<+ 📦 {
$0.createUnitTests = false
Expand All @@ -72,9 +89,6 @@ TestSupport
Networking,
Helpers,
]
$0.with = [
.concurrencyExtras
]
}

/// ------------------------------------------------------------
Expand Down Expand Up @@ -115,6 +129,9 @@ extension Target.Dependency {
static let dependencies: Target.Dependency = .product(
name: "Dependencies", package: "swift-dependencies"
)
static let dependenciesMacros: Target.Dependency = .product(
name: "DependenciesMacros", package: "swift-dependencies"
)
static let deque: Target.Dependency = .product(
name: "DequeModule", package: "swift-collections"
)
Expand All @@ -127,6 +144,9 @@ extension Target.Dependency {
static let httpTypesFoundation: Target.Dependency = .product(
name: "HTTPTypesFoundation", package: "swift-http-types"
)
static let protected: Target.Dependency = .product(
name: "Protected", package: "swift-utilities"
)
static let shortID: Target.Dependency = .product(
name: "ShortID", package: "swift-utilities"
)
Expand All @@ -145,13 +165,12 @@ extension Target.Dependency {
extension [SwiftSetting] {
#if swift(>=6)
static let concurrency: Self = [
.enableUpcomingFeature("StrictConcurrency"),
.enableUpcomingFeature("InferSendableFromCaptures"),
// Already enabled
]
#else
static let concurrency: Self = [
.enableExperimentalFeature("GlobalConcurrency"),
.enableExperimentalFeature("StrictConcurrency"),
.enableExperimentalFeature("TargetedConcurrency"),
.enableExperimentalFeature("InferSendableFromCaptures"),
]
#endif
Expand Down
8 changes: 8 additions & 0 deletions Sources/Helpers/AnySendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

package struct AnySendable: @unchecked Sendable {
package let base: Any
package init<Base: Sendable>(_ base: Base) {
self.base = base
}
}
11 changes: 11 additions & 0 deletions Sources/Helpers/ErrorMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

package struct ErrorMessage: Error, Equatable, ExpressibleByStringLiteral {
let message: String
package init(message: String) {
self.message = message
}
package init(stringLiteral value: String) {
self.init(message: value)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// A system which can asynchronously fetch or refresh credentials
/// in order to make authenticated HTTP requests
public protocol AuthenticationDelegate<Credentials>: Sendable { // swiftlint:disable:this class_delegate_protocol
public protocol AuthenticationDelegate<Credentials>: Actor, Sendable { // swiftlint:disable:this class_delegate_protocol

/// A type which represents the credentials to be used
associatedtype Credentials: AuthenticatingCredentials
Expand All @@ -11,7 +11,7 @@ public protocol AuthenticationDelegate<Credentials>: Sendable { // swiftlint:di
/// and perform whatever actions are necessary to retreive credentials from
/// an external system. For example - present a login interface to the user
/// to collect a username and password.
func fetch(for request: HTTPRequestData) async throws -> Credentials
func authorize() async throws -> Credentials

/// After supplying a request with credentials, it is still possible to
/// encounter HTTP unauthorized errors. In such an event, this method will
Expand All @@ -22,6 +22,12 @@ public protocol AuthenticationDelegate<Credentials>: Sendable { // swiftlint:di
-> Credentials
}

extension AuthenticationDelegate {
public func fetch(for request: HTTPRequestData) async throws -> Credentials {
try await authorize()
}
}

public protocol AuthenticatingCredentials: Hashable, Sendable {

/// The authentication method
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ extension HTTPRequestData {
set { self[option: AuthenticationMethod.self] = newValue }
}
}

extension NetworkingComponent {
public func server(authenticationMethod: AuthenticationMethod) -> some NetworkingComponent {
server { $0.authenticationMethod = authenticationMethod }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ extension HTTPRequestData {
}
}

public typealias BasicAuthentication<
Delegate: AuthenticationDelegate
> = HeaderBasedAuthentication<Delegate> where Delegate.Credentials == BasicCredentials
extension AuthenticationDelegate {
public static func basic(
_ delegate: some AuthenticationDelegate<BasicCredentials>
) -> some AuthenticationDelegate<BasicCredentials> {
AnyAuthenticationDelegate(
delegate: ThreadSafeAuthenticationDelegate(
delegate: delegate
)
)
}
}
Original file line number Diff line number Diff line change
@@ -1,36 +1,57 @@
import Foundation

extension NetworkingComponent {
public func authenticated<Credentials: BearerAuthenticatingCredentials>(
withBearer delegate: some AuthenticationDelegate<Credentials>
) -> some NetworkingComponent {
authenticated(
with: AnyAuthenticationDelegate(
delegate: ThreadSafeAuthenticationDelegate(
delegate: delegate
)
)
)
}
}

extension AuthenticationMethod {
public static let bearer = AuthenticationMethod(rawValue: "Bearer")
}

public struct BearerCredentials: Hashable, Sendable, Codable, HTTPRequestDataOption,
AuthenticatingCredentials
{
public static let method: AuthenticationMethod = .bearer
public static let defaultOption: Self? = nil

public let token: String
public protocol BearerAuthenticatingCredentials: AuthenticatingCredentials {}

public init(token: String) {
self.token = token
}
extension BearerAuthenticatingCredentials {
public static var method: AuthenticationMethod { .bearer }

public func apply(to request: HTTPRequestData) -> HTTPRequestData {
public func apply(token: String, to request: HTTPRequestData) -> HTTPRequestData {
@NetworkEnvironment(\.logger) var logger
var copy = request
let description = "Bearer \(token)"
let authenticationValue = "Bearer \(token)"
logger?
.info(
"""
🔐 \(request.prettyPrintedIdentifier, privacy: .public) \
Applying basic credentials: \(description, privacy: .private)
Applying bearer credentials: \(authenticationValue, privacy: .private)
""")
copy.headerFields[.authorization] = description
copy.headerFields[.authorization] = authenticationValue
return copy
}
}

public struct BearerCredentials: Hashable, Sendable, Codable, HTTPRequestDataOption, BearerAuthenticatingCredentials {
public static let defaultOption: Self? = nil

public let token: String

public init(token: String) {
self.token = token
}

public func apply(to request: HTTPRequestData) -> HTTPRequestData {
apply(token: token, to: request)
}
}

extension HTTPRequestData {
public var bearerCredentials: BearerCredentials? {
get { self[option: BearerCredentials.self] }
Expand All @@ -40,7 +61,3 @@ extension HTTPRequestData {
}
}
}

public typealias BearerAuthentication<
Delegate: AuthenticationDelegate
> = HeaderBasedAuthentication<Delegate> where Delegate.Credentials == BearerCredentials
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

package actor AnyAuthenticationDelegate<Credentials: AuthenticatingCredentials>: AuthenticationDelegate {
package let delegate: any AuthenticationDelegate<Credentials>

package init(delegate: some AuthenticationDelegate<Credentials>) {
self.delegate = delegate
}

package func authorize() async throws -> Credentials {
try await delegate.authorize()
}

package func refresh(unauthorized: Credentials, from response: HTTPResponseData) async throws -> Credentials {
try await delegate.refresh(unauthorized: unauthorized, from: response)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

package actor StreamingAuthenticationDelegate<Delegate: AuthenticationDelegate>: AuthenticationDelegate {
package let delegate: Delegate
private let (stream, continuation) = AsyncStream<Delegate.Credentials>.makeStream()

package var credentials: AsyncStream<Delegate.Credentials> {
stream.shared().eraseToStream()
}

package init(delegate: Delegate) {
self.delegate = delegate
}

package func authorize() async throws -> Delegate.Credentials {
let credentials = try await delegate.authorize()
continuation.yield(credentials)
return credentials
}

package func refresh(unauthorized: Delegate.Credentials, from response: HTTPResponseData) async throws
-> Delegate.Credentials
{
let credentials = try await delegate.refresh(unauthorized: unauthorized, from: response)
continuation.yield(credentials)
return credentials
}
}
Loading

0 comments on commit b5dd469

Please sign in to comment.