diff --git a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift index af5e2b6..c08d39a 100644 --- a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift @@ -13,7 +13,6 @@ public extension URLRequest { /// Attempts to set the authorization header using the auth token. /// - Parameter auth: the oauth authorization - @MainActor mutating func addAuthorization(auth: OAuth.Authorization) { addValue("\(auth.token.type) \(auth.token.accessToken)", forHTTPHeaderField: authHeader) } diff --git a/Sources/OAuthKit/OAuth+Provider.swift b/Sources/OAuthKit/OAuth+Provider.swift index 286e2c2..9aff6b2 100644 --- a/Sources/OAuthKit/OAuth+Provider.swift +++ b/Sources/OAuthKit/OAuth+Provider.swift @@ -40,6 +40,9 @@ extension OAuth { public var customUserAgent: String? /// Enables provider debugging. Off by default. public var debug: Bool + /// An optional regex that can be used in conjuction with the ``OAuth/URLProtocol`` class to intercept outbound requests + /// and inject an `Authorization: Bearer <>` header into a request. + public var authorizationPattern: String? /// The coding keys. enum CodingKeys: String, CodingKey { @@ -55,6 +58,7 @@ extension OAuth { case encodeHttpBody case customUserAgent case debug + case authorizationPattern } /// Public initializer. @@ -71,6 +75,7 @@ extension OAuth { /// - encodeHttpBody: If the provider should encode the access token parameters into the http body (true by default) /// - customUserAgent: The custom user agent to send with browser requests. /// - debug: Boolean to pass debugging into to the standard output (false by default) + /// - authorizationPattern: a regex pattern used to automatically match outbound url requests that should have authorization headers added public init(id: String, icon: URL? = nil, authorizationURL: URL, @@ -82,7 +87,8 @@ extension OAuth { scope: [String]? = nil, encodeHttpBody: Bool = true, customUserAgent: String? = nil, - debug: Bool = false) { + debug: Bool = false, + authorizationPattern: String? = nil) { self.id = id self.icon = icon self.authorizationURL = authorizationURL @@ -95,6 +101,7 @@ extension OAuth { self.encodeHttpBody = encodeHttpBody self.customUserAgent = customUserAgent self.debug = debug + self.authorizationPattern = authorizationPattern } /// Custom decoder initializer. @@ -114,6 +121,7 @@ extension OAuth { encodeHttpBody = try container.decodeIfPresent(Bool.self, forKey: .encodeHttpBody) ?? true customUserAgent = try container.decodeIfPresent(String.self, forKey: .customUserAgent) debug = try container.decodeIfPresent(Bool.self, forKey: .debug) ?? false + authorizationPattern = try container.decodeIfPresent(String.self, forKey: .authorizationPattern) } } } diff --git a/Sources/OAuthKit/OAuth+URLProtocol.swift b/Sources/OAuthKit/OAuth+URLProtocol.swift new file mode 100644 index 0000000..7ff9f12 --- /dev/null +++ b/Sources/OAuthKit/OAuth+URLProtocol.swift @@ -0,0 +1,86 @@ +// +// OAuth+URLProtocol.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +extension OAuth { + + /// A custom `URLProtocol` that can be registered with any `URLSessionConfiguration` that will automatically inject + /// `Authorization: Bearer <>` headers into outbound HTTP URLRequests based on ``Provider/authorizationPattern``. + public class URLProtocol: Foundation.URLProtocol { + + /// The lock that provides manual synchronization around access to authorization tokens. + private static let lock: NSLock = .init() + nonisolated(unsafe) private static var _authorizations: [OAuth.Provider: OAuth.Authorization] = .init() + + static var authorizations: [OAuth.Provider: OAuth.Authorization] { + get { lock.withLock { _authorizations } } + set { lock.withLock { _authorizations = newValue } } + } + + /// Adds an authorization for the given provider that can be used inject `Authorization: Bearer <>` headers into a request. + /// - Parameters: + /// - authorization: the authorization issued by the provider + /// - provider: the provider + @MainActor + public class func addAuthorization(_ authorization: OAuth.Authorization, for provider: OAuth.Provider) { + guard let _ = provider.authorizationPattern else { return } + authorizations[provider] = authorization + } + + /// Removes authorizations for the specified provider. + /// - Parameter provider: the provider to remove authorization for + @MainActor + public class func removeAuthorization(for provider: OAuth.Provider) { + authorizations.removeValue(forKey: provider) + } + + /// Clears all authorizations out of the protocol. + @MainActor + public class func clear() { + authorizations.removeAll() + } + + /// Determines whether this protocol can handle the given request. + /// - Parameter request: the request to handle + /// - Returns: always true + override public class func canInit(with request: URLRequest) -> Bool { + // Remove any expired authorizations + let expiredEntries = authorizations.filter{ $0.value.isExpired } + for expired in expiredEntries { + authorizations.removeValue(forKey: expired.key) + } + guard let url = request.url, authorizations.isNotEmpty else { return false } + for (provider, _) in authorizations { + guard let pattern = provider.authorizationPattern else { continue } + if url.absoluteString.range(of: pattern, options: .regularExpression) != nil { + return true + } + } + return false + } + + /// If an authorized provider matches the given request, then this method returns a canonical version + /// of the given request with an additional `Authorization: Bearer <>` header field. + /// - Parameter request: the request + /// - Returns: the canonical version of the given request. + override public class func canonicalRequest(for request: URLRequest) -> URLRequest { + guard let url = request.url, authorizations.isNotEmpty else { return request } + for (provider, auth) in authorizations { + guard let pattern = provider.authorizationPattern else { continue } + if url.absoluteString.range(of: pattern, options: .regularExpression) != nil { + var canonicalRequest = request + canonicalRequest.addAuthorization(auth: auth) + return canonicalRequest + } + } + return request + } + } +} + + diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 2b6bd12..526b18e 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -157,6 +157,7 @@ public extension OAuth { func clear() { debugPrint("⚠️ [Clearing oauth state]") keychain.clear() + URLProtocol.clear() state = .empty } } @@ -277,6 +278,7 @@ private extension OAuth { func publish(state: State) { switch state { case .authorized(let provider, let auth): + addAuthorization(provider: provider, authorization: auth) schedule(provider: provider, auth: auth) case .receivedDeviceCode(let provider, let deviceCode): schedule(provider: provider, deviceCode: deviceCode) @@ -286,6 +288,14 @@ private extension OAuth { self.state = state } + /// Adds a received authorization to the ``OAuth/URLProtocol`` class. + /// - Parameters: + /// - provider: the provider + /// - authorization: the authorization issued by the provider + func addAuthorization(provider: Provider, authorization: Authorization) { + URLProtocol.addAuthorization(authorization, for: provider) + } + /// Schedules the provider to be polled for authorization with the specified device token. /// - Parameters: /// - provider: the oauth provider diff --git a/Sources/OAuthKit/OAuthKit.docc/Configuration.md b/Sources/OAuthKit/OAuthKit.docc/Configuration.md index d7edc0f..63109c6 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Configuration.md +++ b/Sources/OAuthKit/OAuthKit.docc/Configuration.md @@ -8,7 +8,7 @@ Explore advanced OAuth configuration options such as Keychain protection and pri purpose: card, source: "config-card", alt: "OAuthKit Framework Configuration") - @PageColor(red) + @PageColor(purple) @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") @Available(tvOS, introduced: "18.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/Authorization.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/Authorization.md index acca32e..f7d50fa 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/Authorization.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/Authorization.md @@ -1,5 +1,4 @@ # ``OAuthKit/OAuth/Authorization`` - @Metadata { @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/GrantType.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/GrantType.md index 507ba5f..e5b5228 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/GrantType.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/GrantType.md @@ -1,5 +1,4 @@ # ``OAuthKit/OAuth/GrantType`` - @Metadata { @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/NetworkMonitor.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/NetworkMonitor.md new file mode 100644 index 0000000..6bed8b9 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/NetworkMonitor.md @@ -0,0 +1,9 @@ +# ``OAuthKit/NetworkMonitor`` +@Metadata { + @Available(iOS, introduced: "18.0") + @Available(macOS, introduced: "15.0") + @Available(tvOS, introduced: "18.0") + @Available(visionOS, introduced: "2.0") + @Available(watchOS, introduced: "11.0") +} + diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md index c83e108..4884944 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md @@ -48,3 +48,6 @@ let oauth: OAuth = .init(.module, options: options) - ``state`` - ``providers`` + +### OAuth Authorization Headers +- ``OAuthKit/OAuth/URLProtocol`` diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/Provider.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/Provider.md index 0a7153b..aa19956 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/Provider.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/Provider.md @@ -1,5 +1,4 @@ # ``OAuthKit/OAuth/Provider`` - @Metadata { @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md index 097bd55..37f7b74 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/State.md @@ -1,5 +1,4 @@ # ``OAuthKit/OAuth/State`` - @Metadata { @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/Token.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/Token.md index 63d1b54..12c8c7a 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Extensions/Token.md +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/Token.md @@ -1,5 +1,4 @@ # ``OAuthKit/OAuth/Token`` - @Metadata { @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/URLProtocol.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/URLProtocol.md new file mode 100644 index 0000000..f138831 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/URLProtocol.md @@ -0,0 +1,20 @@ +# ``OAuthKit/OAuth/URLProtocol`` +@Metadata { + @Available(iOS, introduced: "18.0") + @Available(macOS, introduced: "15.0") + @Available(tvOS, introduced: "18.0") + @Available(visionOS, introduced: "2.0") + @Available(watchOS, introduced: "11.0") +} + +## Overview +The URLProtocol can be registered with any `URLSessionConfiguration` and will automatically inject `Authorization` headers into outbound HTTP URLRequests if patterns are matched. + +### Tutorials + +@Links(visualStyle: list) { + - +} + +- SeeAlso: +[Making Authenticated Requests](https://www.oauth.com/oauth2-servers/making-authenticated-requests/) diff --git a/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md index ed95bfd..6fffc4d 100644 --- a/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md +++ b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md @@ -1,14 +1,11 @@ # Getting Started with OAuthKit - -Learn how to create an observable OAuth instance and start an authorization flow. - @Metadata { @PageKind(article) @PageImage( purpose: card, source: "gettingStarted-card", alt: "Getting Started with OAuthKit") - @PageColor(yellow) + @PageColor(purple) @Available(iOS, introduced: "18.0") @Available(macOS, introduced: "15.0") @Available(tvOS, introduced: "18.0") @@ -16,6 +13,9 @@ Learn how to create an observable OAuth instance and start an authorization flow @Available(watchOS, introduced: "11.0") } +Learn how to create an observable OAuth instance and start an authorization flow. + + ## Overview OAuth 2.0 authorization flows are started by calling ``OAuth/authorize(provider:grantType:)`` with an ``OAuth/Provider`` and ``OAuth/GrantType``. @@ -151,7 +151,7 @@ func handle(state: OAuth.State) { ``` Once the user has successfully authorized with the ``OAuth/Provider``, the ``OAuth/state`` will move to an ``OAuth/State/authorized(_:_:)`` state. You can then automaticaly close the ``OAWebView`` window in your SwiftUI application. -> Tip: Once authorized, a ``OAuth/Authorization/token`` will be inserted into the user's Keychain that can be used in subsequent API requests by inserting the `Authorization` header into an ``Foundation/URLRequest`` via ``OAuthKit/Foundation/URLRequest/addAuthorization(auth:)`` . +> Tip: Once authorized, a ``OAuth/Authorization/token`` will be inserted into the user's Keychain that can be used in subsequent API requests by inserting the `Authorization` header into an `URLRequest` via ``OAuthKit/Foundation/URLRequest/addAuthorization(auth:)`` . ## Presenting Device Codes (tvOS and watchOS) OAuthKit also supports the [OAuth 2.0 Device Code Flow Grant](https://alexbilbie.github.io/2016/04/oauth-2-device-flow-grant/), which is used by apps that don't have access to a web browser (like tvOS or watchOS). To leverage OAuthKit in tvOS or watchOS apps, simply add the `deviceCodeURL` to your ``OAuth/Provider`` and start an ``OAuth/authorize(provider:grantType:)`` flow with the ``OAuth/GrantType/deviceCode`` grantType. @@ -188,4 +188,8 @@ The observable ``OAuth`` instance will proceed to poll the ``OAuth/Provider`` ac > Tip: Click [here](https://oauth.net/2/grant-types/device-code/) to see details of how the OAuth 2.0 Device Code Grant Type works. +### Related Tutorials +@Links(visualStyle: list) { + - +} diff --git a/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md b/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md index 6338fe2..7bcd0c8 100644 --- a/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md +++ b/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md @@ -1,7 +1,4 @@ # ``OAuthKit`` - -A modern and observable framework for implementing OAuth 2.0 authorization flows. - @Metadata { @PageColor(purple) @Available(iOS, introduced: "18.0") @@ -11,6 +8,8 @@ A modern and observable framework for implementing OAuth 2.0 authorization flows @Available(watchOS, introduced: "11.0") } +A modern and observable framework for implementing OAuth 2.0 authorization flows. + ## Overview OAuthKit offers a robust, type-safe, and performant OAuth 2.0 implementation using the observer design pattern. This pattern allows applications to observe an ``OAuth`` object and be notified of ``OAuth/State`` changes. @@ -30,6 +29,12 @@ This has the advantage of avoiding direct coupling with an authorization flow, e - } +### Tutorials + +@Links(visualStyle: list) { + - +} + ## Topics ### Essentials diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-card@2x.jpg b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-card@2x.jpg new file mode 100644 index 0000000..9265f48 Binary files /dev/null and b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-card@2x.jpg differ diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-1.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-1.swift new file mode 100644 index 0000000..eb43039 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-1.swift @@ -0,0 +1,23 @@ +import OAuthKit +import SwiftUI + +@main +struct OAuthApp: App { + + @Environment(\.oauth) + var oauth: OAuth + + /// Build the scene body + var body: some Scene { + + // The main window + WindowGroup { + ContentView() + } + + // The authorization window + WindowGroup(id: "oauth") { + OAWebView(oauth: oauth) + } + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift new file mode 100644 index 0000000..dc4b7a4 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift @@ -0,0 +1,53 @@ +struct ContentView: View { + + @Environment(\.oauth) + var oauth: OAuth + + @Environment(\.openWindow) + var openWindow + + @Environment(\.dismissWindow) + private var dismissWindow + + /// Displays a list of oauth providers. + var providerList: some View { + List(oauth.providers) { provider in + Button(provider.id) { + // Start an authorization flow + } + } + } + + /// The main view body + var body: some View { + VStack { + // Update the view based on the current oauth state + switch oauth.state { + case .empty: + providerList + case .authorizing(let provider, let grantType): + Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") + case .requestingAccessToken(let provider): + Text("Requesting Access Token [\(provider.id)]") + case .requestingDeviceCode(let provider): + Text("Requesting Device Code [\(provider.id)]") + case .authorized(let provider, _): + Button("Authorized [\(provider.id)]") { + oauth.clear() + } + case .receivedDeviceCode(_, let deviceCode): + Text("To login, visit") + Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) + .foregroundStyle(.blue) + Text("and enter the following code:") + Text(deviceCode.userCode) + .padding() + .border(Color.primary) + .font(.title) + } + } + .onChange(of: oauth.state) { _, state in + // Handle state change + } + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift new file mode 100644 index 0000000..ba0178a --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift @@ -0,0 +1,67 @@ +struct ContentView: View { + + @Environment(\.oauth) + var oauth: OAuth + + @Environment(\.openWindow) + var openWindow + + @Environment(\.dismissWindow) + private var dismissWindow + + /// Displays a list of oauth providers. + var providerList: some View { + List(oauth.providers) { provider in + Button(provider.id) { + authorize(provider: provider) + } + } + } + + /// Starts the authorization process for the specified provider. + /// - Parameter provider: the provider to begin authorization for + private func authorize(provider: OAuth.Provider) { + #if canImport(WebKit) + // Use the PKCE grantType for iOS, macOS, visionOS + let grantType: OAuth.GrantType = .pkce(.init()) + #else + // Use the Device Code grantType for tvOS, watchOS + let grantType: OAuth.GrantType = .deviceCode + #endif + // Start the authorization flow + oauth.authorize(provider: provider, grantType: grantType) + } + + /// The main view body + var body: some View { + VStack { + // Update the view based on the current oauth state + switch oauth.state { + case .empty: + providerList + case .authorizing(let provider, let grantType): + Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") + case .requestingAccessToken(let provider): + Text("Requesting Access Token [\(provider.id)]") + case .requestingDeviceCode(let provider): + Text("Requesting Device Code [\(provider.id)]") + case .authorized(let provider, _): + Button("Authorized [\(provider.id)]") { + oauth.clear() + } + case .receivedDeviceCode(_, let deviceCode): + Text("To login, visit") + Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) + .foregroundStyle(.blue) + Text("and enter the following code:") + Text(deviceCode.userCode) + .padding() + .border(Color.primary) + .font(.title) + } + } + .onChange(of: oauth.state) { _, state in + // Handle state change + } + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift new file mode 100644 index 0000000..eb75dc3 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift @@ -0,0 +1,36 @@ +struct ContentView: View { + + @Environment(\.oauth) + var oauth: OAuth + + @Environment(\.openWindow) + var openWindow + + @Environment(\.dismissWindow) + private var dismissWindow + + /// The main view body + var body: some View { + VStack { + // Update the view based on the current oauth state + } + .onChange(of: oauth.state) { _, state in + handle(state: state) + } + } + + /// Reacts to oauth state changes by opening or closing authorization windows. + /// - Parameter state: the published state change + private func handle(state: OAuth.State) { + #if canImport(WebKit) + switch state { + case .empty, .requestingAccessToken, .requestingDeviceCode: + break + case .authorizing, .receivedDeviceCode: + openWindow(id: "oauth") + case .authorized(_, _): + dismissWindow(id: "oauth") + } + #endif + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-1.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-1.swift new file mode 100644 index 0000000..0742905 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-1.swift @@ -0,0 +1,4 @@ +// 1) Configure the URLSession that your app will use to make API requests with Github +let configuration: URLSessionConfiguration = .ephemeral +configuration.protocolClasses = [OAuth.URLProtocol.self] +let urlSession: URLSession = .init(configuration: configuration) diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-2.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-2.swift new file mode 100644 index 0000000..4af5912 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-2.swift @@ -0,0 +1,14 @@ +// 1) Configure the URLSession that your app will use to make API requests with Github +let configuration: URLSessionConfiguration = .ephemeral +configuration.protocolClasses = [OAuth.URLProtocol.self] +let urlSession: URLSession = .init(configuration: configuration) + +// 2) Configure a Provider that matches all patterns of `api.github.com` +let provider: OAuth.Provider = .init( + id: "GitHub", + authorizationURL: URL(string: "https://github.com/login/oauth/authorize"), + accessTokenURL: URL(string: "https://github.com/login/oauth/access_token"), + clientID: "CLIENT_ID", + clientSecret: "CLIENT_SECRET", + authorizationPattern: "api.github.com" +) diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-3.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-3.swift new file mode 100644 index 0000000..04d9e90 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-3.swift @@ -0,0 +1,13 @@ +// 2) Configure a Provider that matches all patterns of `api.github.com` +let provider: OAuth.Provider = .init( + id: "GitHub", + authorizationURL: URL(string: "https://github.com/login/oauth/authorize"), + accessTokenURL: URL(string: "https://github.com/login/oauth/access_token"), + clientID: "CLIENT_ID", + clientSecret: "CLIENT_SECRET", + authorizationPattern: "api.github.com" +) + +// 3) Authorize the GitHub Provider to start an OAuth flow +let oauth: OAuth = .init(providers: [provider]) +oauth.authorize(provider: provider) diff --git a/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-4.swift b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-4.swift new file mode 100644 index 0000000..24e664a --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/url-protocol-step-4.swift @@ -0,0 +1,7 @@ +// Once the OAuth has been authorized +// send a REST request to GitHub's API for a list of org repos +// The Authorization header will automatically get included +// in the request since it matches `api.github.com` +let url: URL = .init(string: "https://api.github.com/orgs/{ORG}/repos") +let request = URLRequest(url: url) +let (data, response) = try await urlSession.data(for: request) diff --git a/Sources/OAuthKit/OAuthKit.docc/Tutorials/Contents.tutorial b/Sources/OAuthKit/OAuthKit.docc/Tutorials/Contents.tutorial new file mode 100644 index 0000000..69f320b --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Tutorials/Contents.tutorial @@ -0,0 +1,30 @@ +@Metadata { + @PageImage( + purpose: card, + source: "gettingStarted-card", + alt: "Getting Started with OAuthKit") +} + +@Tutorials(name: "Tutorials") { + @Intro(title: "OAuthKit Tutorials") { + Learn how to start OAuth flows, observe state changes, and use authorization tokens in API requests using URLProtocols. + + @Image(source: tutorial-card.jpg, alt: "tutorial") + } + + @Chapter(name: "OAuth Flows and States") { + + @Image(source: gettingStarted-card.jpg, alt: "OAuthKit") + + Learn the basics of how to create observable ``OAuth`` instances and start authorization flows. + @TutorialReference(tutorial: "doc:FlowsStates") + } + + @Chapter(name: "URLProtocols with OAuthKit") { + + @Image(source: blueprint-card.jpg, alt: "OAuthKit") + + Leverage the ``OAuth/URLProtocol`` class to automatically have `Authorization` header values injected into matching outbound `URLRequest`. + @TutorialReference(tutorial: "doc:URLProtocol") + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Tutorials/FlowsStates.tutorial b/Sources/OAuthKit/OAuthKit.docc/Tutorials/FlowsStates.tutorial new file mode 100644 index 0000000..a45e8b5 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Tutorials/FlowsStates.tutorial @@ -0,0 +1,46 @@ +@Tutorial(time: 7) { + @Intro(title: "OAuth Flows and States") { + This tutorial guides you through starting authorization flows, and observing ``OAuth/state``. + + @Image(source: gettingStarted-card.jpg, alt: "OAuthKit") + } + + @Section(title: "Observing OAuth Objects") { + @ContentAndMedia { + This section walks you through an example of how to use an observable ``OAuth`` object. + } + + @Steps { + @Step { + Declare a default ``OAuth`` instance via the @Environment property wrapper in SwiftUI that can be accessed throughout your app. + + > Tip: For details of how to create and configure an ``OAuth`` instance, see + @Code(name: "OAuthApp.swift", file: flows-states-step-1.swift) + } + + @Step { + Observe the ``OAuth/state`` property for changes in your View. + @Code(name: "ContentView.swift", file: flows-states-step-2.swift) + } + + @Step { + Start an flow for a `Provider` when a user taps a `Provider` in the list. + + > Tip: OAuthKit also supports the [OAuth 2.0 Device Code Flow Grant](https://alexbilbie.github.io/2016/04/oauth-2-device-flow-grant/), which is used by apps that don't have access to a web browser (like tvOS or watchOS). To leverage OAuthKit in tvOS or watchOS apps, simply add the ``OAuth/Provider/deviceCodeURL`` to your `Provider` and start an authorization flow with the `.deviceCode` grantType. + @Code(name: "ContentView.swift", file: flows-states-step-3.swift) + } + + @Step { + Open the authorization window when the ``OAuth/state`` reaches an ``OAuth/State/authorizing(_:_:)`` state. + @Code(name: "ContentView.swift", file: flows-states-step-4.swift) + } + + @Step { + Once the ``OAuth/Provider`` has been authorized, the ``OAuth/state`` will reach an ``OAuth/State/authorized(_:_:)`` state can you can dismiss the authorization window. + + > Tip: See using the received authorization token in subsequent API requests in the tutorial. + @Code(name: "ContentView.swift", file: flows-states-step-4.swift) + } + } + } +} diff --git a/Sources/OAuthKit/OAuthKit.docc/Tutorials/URLProtocol.tutorial b/Sources/OAuthKit/OAuthKit.docc/Tutorials/URLProtocol.tutorial new file mode 100644 index 0000000..ac22f74 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Tutorials/URLProtocol.tutorial @@ -0,0 +1,40 @@ +@Tutorial(time: 5) { + @Intro(title: "Using URLProtocols with OAuthKit") { + This tutorial guides you through using the ``OAuth/URLProtocol`` for automatically injecting `Authorization` headers into matching outbound HTTP URLRequests using the ``OAuth/Provider/authorizationPattern``. + + @Image(source: blueprint-card.jpg, alt: "OAuthKit") + } + + @Section(title: "Leverage the OAuth.URLProtocol with an URLSession.") { + @ContentAndMedia { + + This section walks you through an example of configuring an ``OAuth/Provider`` with [GitHub](https://docs.github.com/en/rest) and using the ``OAuth/URLProtocol`` for authorized URL requests with GitHub's REST endpoints. + } + + @Steps { + @Step { + Register the ``OAuth/URLProtocol`` class with an URLSession you'll use to communicate with an ``OAuth/Provider`` for API requests. + @Code(name: "Configuration/URLSession.swift", file: url-protocol-step-1.swift) + } + + @Step { + Create a GitHub Provider that matches all REST endpoint patterns of `api.github.com`. + @Code(name: "Configuration/Provider.swift", file: url-protocol-step-2.swift) + } + + @Step { + Start an ``OAuth/authorize(provider:grantType:)`` flow with the GitHub ``OAuth/Provider``. + + > Important: You'll need to wait until you've been authorized by GitHub and your ``OAuth/State`` has moved to ``OAuth/State/authorized(_:_:)`` + + > See: + @Code(name: "Configuration/AuthFlow.swift", file: url-protocol-step-3.swift) + } + + @Step { + Once the GitHub ``OAuth/Provider`` has been authorized, your app can begin to send authorized requests to any endpoints that match `api.github.com`. The ``OAuth/URLProtocol`` will automatically intercept every outbound request that matches `api.github.com` and inject the `Authorization: Bearer <>` header for you. + @Code(name: "Configuration/Request.swift", file: url-protocol-step-4.swift) + } + } + } +} diff --git a/Tests/OAuthKitTests/CodableTests.swift b/Tests/OAuthKitTests/CodableTests.swift index a0b5403..b8ea222 100644 --- a/Tests/OAuthKitTests/CodableTests.swift +++ b/Tests/OAuthKitTests/CodableTests.swift @@ -25,7 +25,8 @@ struct CodableTests { clientSecret: "CLIENT_SECRET", scope: ["email"], customUserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15", - debug: true) + debug: true, + authorizationPattern: "github.com") let data = try encoder.encode(provider) let decoded: OAuth.Provider = try decoder.decode(OAuth.Provider.self, from: data) diff --git a/Tests/OAuthKitTests/OAuthTests.swift b/Tests/OAuthKitTests/OAuthTests.swift index 28a5cd7..3ea01ac 100644 --- a/Tests/OAuthKitTests/OAuthTests.swift +++ b/Tests/OAuthKitTests/OAuthTests.swift @@ -421,6 +421,106 @@ final class OAuthTests { #expect(random.count >= 43) } + /// Tests the OAuth URLProtocol Authorization Request Matches + @Test("OAuth Provider Authorization Request Matches") + func whenMatchingAuthorizationRequests() async throws { + + // Provider that matches patterns for adding authorization headers + let provider: OAuth.Provider = + .init(id: .secureRandom(), + authorizationURL: URL(string: "https://example.com/auth")!, + accessTokenURL: URL(string: "https://example.com/token")!, + clientID: .secureRandom(), + clientSecret: .secureRandom(), + authorizationPattern: "(example.com|example.org)" + ) + + let token: OAuth.Token = .init(accessToken: .secureRandom(), + refreshToken: nil, + expiresIn: 3600, + scope: nil, + type: "Bearer") + let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) + OAuth.URLProtocol.addAuthorization(auth, for: provider) + + var urls: [String] = [ + "https://api.example.com/endpoint", + "https://api.example.org/endpoint", + "https://www.example.com/secure", + ] + + // 1) Success handling + for urlString in urls { + let request: URLRequest = .init(url: URL(string: urlString)!) + let canHandle = OAuth.URLProtocol.canInit(with: request) + #expect(canHandle == true) + + let canonicalRequest = OAuth.URLProtocol.canonicalRequest(for: request) + let authorizationHeader = canonicalRequest.value(forHTTPHeaderField: "Authorization") + #expect(authorizationHeader == "\(token.type) \(token.accessToken)") + } + + // 2) Expired authorization + let expiredToken: OAuth.Token = .init(accessToken: .secureRandom(), + refreshToken: nil, + expiresIn: 0, + scope: nil, + type: "Bearer") + let expiredAuth: OAuth.Authorization = .init(issuer: provider.id, token: expiredToken) + OAuth.URLProtocol.addAuthorization(expiredAuth, for: provider) + + for urlString in urls { + let request: URLRequest = .init(url: URL(string: urlString)!) + let canHandle = OAuth.URLProtocol.canInit(with: request) + #expect(canHandle == false) + + let canonicalRequest = OAuth.URLProtocol.canonicalRequest(for: request) + let authorizationHeader = canonicalRequest.value(forHTTPHeaderField: "Authorization") + #expect(authorizationHeader == nil) + } + + // 3) Removed authorization + OAuth.URLProtocol.removeAuthorization(for: provider) + + for urlString in urls { + let request: URLRequest = .init(url: URL(string: urlString)!) + let canHandle = OAuth.URLProtocol.canInit(with: request) + #expect(canHandle == false) + + let canonicalRequest = OAuth.URLProtocol.canonicalRequest(for: request) + let authorizationHeader = canonicalRequest.value(forHTTPHeaderField: "Authorization") + #expect(authorizationHeader == nil) + } + + + // 4) No pattern matching + let provider2: OAuth.Provider = + .init(id: .secureRandom(), + authorizationURL: URL(string: "https://example.com/auth")!, + accessTokenURL: URL(string: "https://example.com/token")!, + clientID: .secureRandom(), + clientSecret: .secureRandom() + ) + OAuth.URLProtocol.addAuthorization(auth, for: provider2) + + urls = [ + "https://api.codefiesta.com/endpoint", + "https://codefiesta.com/codefiesta", + ] + + for urlString in urls { + let request: URLRequest = .init(url: URL(string: urlString)!) + let canHandle = OAuth.URLProtocol.canInit(with: request) + #expect(canHandle == false) + + let canonicalRequest = OAuth.URLProtocol.canonicalRequest(for: request) + let authorizationHeader = canonicalRequest.value(forHTTPHeaderField: "Authorization") + #expect(authorizationHeader == nil) + } + + OAuth.URLProtocol.clear() + } + /// Streams the oauth status until we receive an authorization. /// This should only be used on test methods that expect an authorization to be inserted into the keychain. private func waitForAuthorization(_ oauth: OAuth) async -> Bool { diff --git a/Tests/OAuthKitTests/Resources/oauth.json b/Tests/OAuthKitTests/Resources/oauth.json index dbfd0f3..207c455 100644 --- a/Tests/OAuthKitTests/Resources/oauth.json +++ b/Tests/OAuthKitTests/Resources/oauth.json @@ -54,7 +54,8 @@ "user", "repo" ], - "debug": true + "debug": true, + "authorizationPattern": "api.github.com" }, { "id": "Google",