diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml new file mode 100644 index 0000000..0b7c4d2 --- /dev/null +++ b/.github/workflows/docc.yml @@ -0,0 +1,54 @@ +# Generates and Deploys DocC documentation to Github pages +# See: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 +name: DocC + +on: + push: + branches: [ "main" ] + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + + # Single deploy job since we're just deploying + docc: + + env: + XCODE_VERSION: 'Xcode_16.4' + + environment: + # Must be set to this for deploying to GitHub Pages + name: github-pages + url: ${{steps.deployment.outputs.page_url}} + + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + run: sudo xcode-select -s "/Applications/$XCODE_VERSION.app" + - name: Generate DocC + run: xcodebuild docbuild -scheme OAuthKit -derivedDataPath /tmp/docbuild -destination 'generic/platform=macOS'; + - name: Transform DocC + run: | + $(xcrun --find docc) process-archive \ + transform-for-static-hosting /tmp/docbuild/Build/Products/Debug/OAuthKit.doccarchive \ + --hosting-base-path OAuthKit \ + --output-path docs; + - name: Update Index + run: echo "" > docs/index.html; + - name: Upload Pages Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs' + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index ca3884f..51c42f2 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -12,6 +12,7 @@ on: jobs: build: + env: XCODE_VERSION: 'Xcode_16.4' runs-on: macos-15 diff --git a/.gitignore b/.gitignore index 8300ca8..f3a5856 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +Package.resolved \ No newline at end of file diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..85d47a3 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,3 @@ +version: 1 +external_links: + documentation: "https://codefiesta.github.io/OAuthKit/documentation/oauthkit" diff --git a/Sources/OAuthKit/Network/NetworkMonitor.swift b/Sources/OAuthKit/Network/NetworkMonitor.swift index 37b12b7..d3ae851 100644 --- a/Sources/OAuthKit/Network/NetworkMonitor.swift +++ b/Sources/OAuthKit/Network/NetworkMonitor.swift @@ -8,7 +8,7 @@ import Network import Observation -/// A type that broadcasts network reachability via Combine event publishing. +/// An `Observable` type that publishes network reachability information. @MainActor @Observable public final class NetworkMonitor { diff --git a/Sources/OAuthKit/OAuth+DeviceCode.swift b/Sources/OAuthKit/OAuth+DeviceCode.swift index 942c43c..8a6f3a9 100644 --- a/Sources/OAuthKit/OAuth+DeviceCode.swift +++ b/Sources/OAuthKit/OAuth+DeviceCode.swift @@ -10,8 +10,8 @@ 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 + /// - SeeAlso: + /// [Requesting a Device Code](https://www.oauth.com/playground/device-code.html) public struct DeviceCode: Codable, Equatable, Sendable { /// A constant for the oauth grant type. diff --git a/Sources/OAuthKit/OAuth+Provider.swift b/Sources/OAuthKit/OAuth+Provider.swift index 237e45c..6e83e4f 100644 --- a/Sources/OAuthKit/OAuth+Provider.swift +++ b/Sources/OAuthKit/OAuth+Provider.swift @@ -57,7 +57,7 @@ extension OAuth { case debug } - /// Public initializer + /// Public initializer. /// - Parameters: /// - id: The provider unique id /// - icon: The provider icon diff --git a/Sources/OAuthKit/OAuth+State.swift b/Sources/OAuthKit/OAuth+State.swift index e7127b2..87031c0 100644 --- a/Sources/OAuthKit/OAuth+State.swift +++ b/Sources/OAuthKit/OAuth+State.swift @@ -9,7 +9,44 @@ import Foundation extension OAuth { - /// Holds the OAuth state that is published to subscribers via the `OAuth.state` property publisher. + /// Holds the OAuth state that is published to subscribers via the ``state`` property. + /// + /// A n example of observing the ``state`` property in SwiftUI: + /// ```swift + ///struct ContentView: View { + /// + /// @Environment(\.oauth) + /// var oauth: OAuth + /// + /// var body: some View { + /// VStack { + /// 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) + /// } + /// } + /// .onChange(of: oauth.state) { oldState, newState in + /// handle(state: newState) + /// } + /// } + /// } + /// ``` public enum State: Equatable, Sendable { /// The state is empty and no authorizations or tokens have been issued. diff --git a/Sources/OAuthKit/OAuth+Token.swift b/Sources/OAuthKit/OAuth+Token.swift index 5094df8..7267d77 100644 --- a/Sources/OAuthKit/OAuth+Token.swift +++ b/Sources/OAuthKit/OAuth+Token.swift @@ -10,8 +10,8 @@ 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 + /// - SeeAlso: + /// [Access Token Response](https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/) public struct Token: Codable, Equatable, Sendable { /// The access token string as issued by the authorization server. diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index fb6b12f..534c5b0 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -29,8 +29,30 @@ public enum OAError: Error { case keychain } -/// Provides an observable OAuth 2.0 implementation. -/// See: https://datatracker.ietf.org/doc/html/rfc6749 +/// Provides an `Observable` OAuth 2.0 implementation that emits ``OAuth/state`` changes when +/// an authorization flow is started by calling ``OAuth/authorize(provider:grantType:)``. +/// +/// You can create an observable OAuth object using the ``OAuth/init(_:options:)`` or ``OAuth/init(providers:options:)`` initializers, or +/// you can access a default OAuth object via SwiftUI ``SwiftUICore/EnvironmentValues`` via the following: +/// +/// ```swift +/// @Environment(\.oauth) +/// var oauth: OAuth +/// ``` +/// +/// An OAuth object can also be highly customized when passed a dictionary of ``Option`` values into it's iniitializers. +/// ```swift +/// let options: [OAuth.Option: Any] = [ +/// .applicationTag: "com.bundle.Idenfitier", +/// .autoRefresh: true, +/// .requireAuthenticationWithBiometricsOrCompanion: true, +/// .useNonPersistentWebDataStore: true +/// ] +/// let oauth: OAuth = .init(.module, options: options) +/// ``` +/// +/// - SeeAlso: +/// [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) @MainActor @Observable public final class OAuth { diff --git a/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md b/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md new file mode 100644 index 0000000..44206c7 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md @@ -0,0 +1,17 @@ +# ``OAuthKit/OAuth`` + +## Topics + +### Creating an Observable OAuth + +- ``init(_:options:)`` +- ``init(providers:options:)`` + +### Starting an authorization flow + +- ``authorize(provider:grantType:)`` + +### Observing an OAuth object + +- ``state`` +- ``providers`` diff --git a/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md new file mode 100644 index 0000000..f20f516 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/GettingStarted.md @@ -0,0 +1,122 @@ +# Getting Started with OAuthKit + +Create an ``OAuth`` instance and start an authorization flow. + +@Metadata { + @PageKind(article) +} + +## Overview + +OAuth 2.0 authorization flows are started by calling ``OAuth/authorize(provider:grantType:)`` with an ``OAuth/Provider`` and ``OAuth/GrantType``. + +```swift +// Create an observable OAuth object +let oauth: OAuth = .init(.main) + +// Start an authorization flow +let grantType: OAuth.GrantType = .pkce(.init()) +oauth.authorize(provider: provider, grantType: grantType) +``` + +## Observing OAuth State + +Once an OAuth 2.0 authorization flow has been started in an application, observers of an OAuth object will be notified of next step events in that flow and can react accordingly. For example: + +```swift +@main +struct OAuthApp: App { + + @Environment(\.oauth) + var oauth: OAuth + + /// Build the scene body + var body: some Scene { + + WindowGroup { + ContentView() + } + + #if canImport(WebKit) + WindowGroup(id: "oauth") { + OAWebView(oauth: oauth) + } + #endif + } +} + +struct ContentView: View { + + @Environment(\.oauth) + var oauth: OAuth + + @Environment(\.openWindow) + var openWindow + + @Environment(\.dismissWindow) + private var dismissWindow + + var body: some View { + VStack { + 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: state) + } + } + + /// 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) { + let grantType: OAuth.GrantType = .pkce(.init()) + oauth.authorize(provider: provider, grantType: grantType) + } + + /// 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/OAuthKit.md b/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md new file mode 100644 index 0000000..89ebac4 --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/OAuthKit.md @@ -0,0 +1,31 @@ +# ``OAuthKit`` + +A modern and observable framework for implementing OAuth 2.0 authorization flows. + +@Metadata { + @PageColor(purple) +} + +## 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. + +This has the advantage of avoiding direct coupling with an authorization flow, enabling highly flexible and fine-grained control when interacting with an ``OAuth/Provider``. It also allows updates across multiple observers. + +### Featured + +@Links(visualStyle: detailedGrid) { + - + - +} + +## Topics + +### Essentials + +- +- +- ``OAuth`` + +### State Observability +- ``OAuth/state`` diff --git a/Sources/OAuthKit/OAuthKit.docc/SampleCode.md b/Sources/OAuthKit/OAuthKit.docc/SampleCode.md new file mode 100644 index 0000000..030ecbd --- /dev/null +++ b/Sources/OAuthKit/OAuthKit.docc/SampleCode.md @@ -0,0 +1,15 @@ +# OAuthKit Sample: Integrating OAuthKit into an App + +Integrate OAuthKit into a multiplatform app with support for iOS, macOS, tvOS, visionOS, and watchOS. + +@Metadata { + @CallToAction( + purpose: link, + url: "https://github.com/codefiesta/OAuthSample" + ) + @PageKind(sampleCode) +} + +## Overview + +This sample creates _OAuthSample_, an app for demonstrating how to get up and running quickly with OAuthKit. diff --git a/Sources/OAuthKit/Views/OAWebView.swift b/Sources/OAuthKit/Views/OAWebView.swift index 57bfcd4..7e9069b 100644 --- a/Sources/OAuthKit/Views/OAWebView.swift +++ b/Sources/OAuthKit/Views/OAWebView.swift @@ -10,7 +10,7 @@ import SwiftUI import WebKit /// A UIViewRepresentable / NSViewRepresentable wrapper type that coordinates -/// oauth authorization flows inside a WKWebView. +/// oauth authorization flows inside a `WKWebView`. @MainActor public struct OAWebView { diff --git a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift index cf1992e..8f23c82 100644 --- a/Sources/OAuthKit/Views/OAWebViewCoordinator.swift +++ b/Sources/OAuthKit/Views/OAWebViewCoordinator.swift @@ -9,6 +9,7 @@ import SwiftUI import WebKit +/// Coordinates ``OAuth/state-swift.property`` changes inside a ``OAWebView``. @MainActor public class OAWebViewCoordinator: NSObject {