├── .spi.yml ├── Sources └── OAuthKit │ ├── OAuthKit.docc │ ├── Resources │ │ ├── config-card@2x.jpg │ │ ├── tutorial-card@2x.jpg │ │ ├── blueprint-card@2x.jpg │ │ ├── gettingStarted-card@2x.jpg │ │ └── tutorial-code-files │ │ │ ├── flows-states-step-1.swift │ │ │ ├── flows-states-step-4.swift │ │ │ ├── flows-states-step-2.swift │ │ │ └── flows-states-step-3.swift │ ├── Extensions │ │ ├── NetworkMonitor.md │ │ ├── EnvironmentValues.md │ │ ├── OAWebView.md │ │ ├── OAWebViewCoordinator.md │ │ ├── Authorization.md │ │ ├── Token.md │ │ ├── GrantType.md │ │ ├── OAuth.md │ │ ├── State.md │ │ └── Provider.md │ ├── theme-settings.json │ ├── Tutorials │ │ ├── Contents.tutorial │ │ └── FlowsStates.tutorial │ ├── SampleCode.md │ ├── OAuthKit.md │ ├── Configuration.md │ └── GettingStarted.md │ ├── Extensions │ ├── Collection+Extensions.swift │ ├── UUID+Extensions.swift │ ├── URLRequest+Extensions.swift │ ├── Date+Extensions.swift │ ├── EnvironmentValues+Extensions.swift │ ├── URLResponse+Extensions.swift │ ├── URL+Extensions.swift │ ├── Task+Extensions.swift │ ├── Digest+Extensions.swift │ ├── Data+Extensions.swift │ └── String+Extensions.swift │ ├── OAuth+PKCE.swift │ ├── OAuth+Authorization.swift │ ├── Network │ └── NetworkMonitor.swift │ ├── Views │ ├── OAWebView.swift │ └── OAWebViewCoordinator.swift │ ├── OAuth+State.swift │ ├── OAuth+GrantType.swift │ ├── OAuth+Option.swift │ ├── OAuth+Token.swift │ ├── OAuth+DeviceCode.swift │ ├── OAuth+Provider.swift │ ├── Keychain │ └── Keychain.swift │ ├── OAuth+Request.swift │ └── OAuth.swift ├── .gitignore ├── Tests └── OAuthKitTests │ ├── Tags+Extensions.swift │ ├── OAuthTestWKNavigationAction.swift │ ├── OAuthTestLAContext.swift │ ├── KeychainTests.swift │ ├── OAuth+Monitor.swift │ ├── CodableTests.swift │ ├── Resources │ └── oauth.json │ ├── OAuthTestURLProtocol.swift │ ├── OAWebViewTests.swift │ ├── UtilityTests.swift │ └── OAuthTests.swift ├── .swiftlint.yml ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── swift.yml │ ├── docc.yml │ └── codeql.yml ├── SECURITY.md ├── LICENSE ├── Package.swift ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [OAuthKit] 5 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/config-card@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefiesta/OAuthKit/HEAD/Sources/OAuthKit/OAuthKit.docc/Resources/config-card@2x.jpg -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-card@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefiesta/OAuthKit/HEAD/Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-card@2x.jpg -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/blueprint-card@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefiesta/OAuthKit/HEAD/Sources/OAuthKit/OAuthKit.docc/Resources/blueprint-card@2x.jpg -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/gettingStarted-card@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codefiesta/OAuthKit/HEAD/Sources/OAuthKit/OAuthKit.docc/Resources/gettingStarted-card@2x.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/ 7 | .swiftpm/configuration/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | Package.resolved -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Extensions.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | extension Collection { 11 | 12 | /// Returns true if the collection is not empty. 13 | @inlinable 14 | public var isNotEmpty: Bool { !isEmpty } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/NetworkMonitor.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/NetworkMonitor`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/Tags+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tags+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Testing 9 | 10 | extension Tag { 11 | @Tag static var oauth: Self 12 | @Tag static var keychain: Self 13 | @Tag static var utility: Self 14 | @Tag static var views: Self 15 | } 16 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "aside": { 4 | "border-radius": "8px", 5 | "border-width": "3px" 6 | }, 7 | "color": { 8 | "aside-tip-border": "rgb(106, 162, 148)", 9 | "aside-important-border": "rgb(244, 184, 66)" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/UUID+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UUID+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension UUID { 12 | 13 | /// Returns the SHA-256 Digest for this UUID. 14 | var sha256: SHA256.Digest { 15 | uuidString.sha256 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/EnvironmentValues.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/SwiftUICore/EnvironmentValues`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/OAWebView.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAWebView`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(visionOS, introduced: "2.0") 6 | } 7 | 8 | > Important: The ``OAWebView`` is only available for Apple operating systems with browsers. If you want to start 9 | an ``OAuth/authorize(provider:grantType:)`` flow on tvOS or watchOS, be sure to use the 10 | ``OAuth/GrantType/deviceCode`` grant Type. See for more details. 11 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | only_rules: 2 | - closing_brace 3 | - colon 4 | - comma 5 | - duplicate_imports 6 | - explicit_init 7 | - file_header 8 | - function_body_length 9 | - legacy_constructor 10 | - legacy_nsgeometry_functions 11 | - legacy_constant 12 | - modifier_order 13 | - private_subject 14 | - redundant_self_in_closure 15 | - sorted_imports 16 | - toggle_bool 17 | - trailing_whitespace 18 | - unneeded_break_in_switch 19 | - unused_import 20 | - weak_delegate 21 | 22 | # rule parameters 23 | function_body_length: 24 | - 225 # warning 25 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-1.swift: -------------------------------------------------------------------------------- 1 | import OAuthKit 2 | import SwiftUI 3 | 4 | @main 5 | struct OAuthApp: App { 6 | 7 | @Environment(\.oauth) 8 | var oauth: OAuth 9 | 10 | /// Build the scene body 11 | var body: some Scene { 12 | 13 | // The main window 14 | WindowGroup { 15 | ContentView() 16 | } 17 | 18 | // The authorization window 19 | WindowGroup(id: "oauth") { 20 | OAWebView(oauth: oauth) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/OAWebViewCoordinator.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAWebViewCoordinator`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(visionOS, introduced: "2.0") 6 | } 7 | 8 | > Important: The ``OAWebViewCoordinator`` is only available for Apple operating systems with browsers. If you want to start 9 | an ``OAuth/authorize(provider:grantType:)`` flow on tvOS or watchOS, be sure to use the 10 | ``OAuth/GrantType/deviceCode`` grant Type. See for more details. 11 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/URLRequest+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLRequest+Extensions.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | private let authHeader = "Authorization" 11 | 12 | public extension URLRequest { 13 | 14 | /// Attempts to set the authorization header using the auth token. 15 | /// - Parameter auth: the oauth authorization 16 | mutating func addAuthorization(auth: OAuth.Authorization) { 17 | addValue("\(auth.token.type) \(auth.token.accessToken)", forHTTPHeaderField: authHeader) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/Date+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Extensions.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Date { 11 | 12 | /// A custom operator that returns the timeinterval difference between the two dates. 13 | /// - Parameters: 14 | /// - lhs: the left hand date 15 | /// - rhs: the right hand date 16 | /// - Returns: the time interval between the two dates. 17 | static func - (lhs: Date, rhs: Date) -> TimeInterval { 18 | lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/EnvironmentValues+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+Extensions.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension EnvironmentValues { 11 | 12 | /// Exposes `@Environment(\.oauth) var oauth` to Views. 13 | var oauth: OAuth { 14 | get { self[OAuthKey.self] } 15 | set { self[OAuthKey.self] = newValue } 16 | } 17 | } 18 | 19 | struct OAuthKey: @preconcurrency EnvironmentKey { 20 | 21 | /// The default OAuth instance that is loaded into the environment. 22 | @MainActor static let defaultValue: OAuth = .init(.main) 23 | } 24 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/URLResponse+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLResponse+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | public extension URLResponse { 11 | 12 | /// Returns true if the response status code is in the 200's. 13 | var isOK: Bool { 14 | guard let code = statusCode() else { return false } 15 | return 200...299 ~= code 16 | } 17 | 18 | /// Extracts the status code from the response. 19 | func statusCode() -> Int? { 20 | guard let httpResponse = self as? HTTPURLResponse else { return nil } 21 | return httpResponse.statusCode 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Tutorials/Contents.tutorial: -------------------------------------------------------------------------------- 1 | @Metadata { 2 | @PageImage( 3 | purpose: card, 4 | source: "gettingStarted-card", 5 | alt: "Getting Started with OAuthKit") 6 | } 7 | 8 | @Tutorials(name: "Tutorials") { 9 | @Intro(title: "OAuthKit Tutorials") { 10 | Learn how to start OAuth flows and observe state changes. 11 | 12 | @Image(source: tutorial-card.jpg, alt: "tutorial") 13 | } 14 | 15 | @Chapter(name: "OAuth Flows and States") { 16 | 17 | @Image(source: gettingStarted-card.jpg, alt: "OAuthKit") 18 | 19 | Learn the basics of how to create observable ``OAuth`` instances and start authorization flows. 20 | @TutorialReference(tutorial: "doc:FlowsStates") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Platform (please complete the following information):** 27 | - OS: [e.g. macOS, iOS, visionOS] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension URL { 12 | 13 | /// Returns the SHA-256 Digest for this URL. 14 | var sha256: SHA256.Digest { 15 | absoluteString.sha256 16 | } 17 | 18 | /// Returns the SHA-512 Digest for this URL. 19 | var sha512: SHA512.Digest { 20 | absoluteString.sha512 21 | } 22 | 23 | /// Returns a Base64 encoded string representation for this URL 24 | var base64: String { 25 | absoluteString.base64 26 | } 27 | 28 | /// Returns a Base64 URL encoded string representation for this URL. 29 | var base64URL: String { 30 | absoluteString.base64URL 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/SampleCode.md: -------------------------------------------------------------------------------- 1 | # OAuthSample: Integrating OAuthKit into an App 2 | 3 | Integrate OAuthKit into a multiplatform app with support for iOS, macOS, tvOS, visionOS, and watchOS. 4 | 5 | @Metadata { 6 | @CallToAction( 7 | purpose: link, 8 | url: "https://github.com/codefiesta/OAuthSample" 9 | ) 10 | @PageKind(sampleCode) 11 | @PageImage( 12 | purpose: card, 13 | source: "blueprint-card", 14 | alt: "OAuthKit Sample Code") 15 | @Available(iOS, introduced: "18.0") 16 | @Available(macOS, introduced: "15.0") 17 | @Available(tvOS, introduced: "18.0") 18 | @Available(visionOS, introduced: "2.0") 19 | @Available(watchOS, introduced: "11.0") 20 | } 21 | 22 | ## Overview 23 | 24 | The OAuthSample app demonstrates how to get up and running quickly with OAuthKit. 25 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/Authorization.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth/Authorization`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | When an ``OAuth/Provider`` has issued an access ``token``, an `Authorization` is created and stored inside the user's `Keychain`. The `Authorization` holds an access ``token`` along with additional properties about the authorization such as the ``issuer``, when the token was ``issued``, and it's ``expiration``. 12 | 13 | - SeeAlso: 14 | [Access Token Response](https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/) 15 | 16 | ## Topics 17 | 18 | ### Essentials 19 | 20 | - ``OAuth/State`` 21 | - ``OAuth/State/authorized(_:_:)`` 22 | - ``Foundation/URLRequest/addAuthorization(auth:)`` 23 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/Task+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Task+Extensions.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | private let nanoSeconds: Double = 1_000_000_000 11 | 12 | extension Task where Failure == Error { 13 | 14 | /// Builds a delayed task. 15 | /// - Parameters: 16 | /// - timeInterval: the delay interval 17 | /// - priority: the task priority 18 | /// - operation: the task operation to execute 19 | /// - Returns: a new task that will execute after the specified delay 20 | static func delayed( 21 | timeInterval delayInterval: TimeInterval, 22 | operation: @escaping @Sendable () async throws -> Success 23 | ) -> Task { 24 | 25 | Task { 26 | let delay = UInt64(delayInterval * nanoSeconds) 27 | try await Task.sleep(nanoseconds: delay) 28 | return try await operation() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.x | :white_check_mark: | 8 | | 1.1.x | :white_check_mark: | 9 | | 1.2.x | :white_check_mark: | 10 | | 1.3.x | :white_check_mark: | 11 | | 1.4.x | :white_check_mark: | 12 | | 1.5.x | :white_check_mark: | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | ### Private Disclosure Process 17 | 18 | **@codefiesta** asks that known and suspected vulnerabilities be privately and responsibly disclosed by contacting @codefiesta 19 | with the [details usually included with bug reports][issue-template]. 20 | 21 | **Do not file a public issue.** 22 | 23 | #### When to report a vulnerability 24 | 25 | * You think you have discovered a potential security vulnerability in OAuthKit. 26 | * You are unsure how a vulnerability affects OAuthKit. 27 | 28 | [issue-template]: https://github.com/codefiesta/OAuthKit/blob/main/.github/ISSUE_TEMPLATE/bug_report.md 29 | 30 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/Token.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth/Token`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | A `Token` is the holder of an ``accessToken`` that can be used in an `URLRequest` to provide credentials for authentication, allowing access to protected resources. 12 | 13 | ```swift 14 | /// Adds a header field of 'Authorization: Bearer <>' 15 | let value = "\(token.type) \(token.accessToken)" 16 | var request: URLRequest = .init(url: url) 17 | request.addValue(value, forHTTPHeaderField: "Authorization") 18 | ``` 19 | 20 | - SeeAlso: 21 | [Access Token Response](https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/) 22 | 23 | ## Topics 24 | 25 | ### Essentials 26 | 27 | - ``OAuth/Authorization`` 28 | - ``Foundation/URLRequest/addAuthorization(auth:)`` 29 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAuthTestWKNavigationAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuthTestWKNavigationAction.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | #if canImport(WebKit) 9 | import WebKit 10 | 11 | /// OAuth Test WKNavigationAction that can be used for testing 12 | final class OAuthTestWKNavigationAction: WKNavigationAction { 13 | 14 | /// The url request 15 | let urlRequest: URLRequest 16 | 17 | /// The url request accessor to adhere to WKNavigationAction protocol. 18 | override var request: URLRequest { urlRequest } 19 | 20 | /// The received policy 21 | var receivedPolicy: WKNavigationActionPolicy? 22 | 23 | /// Initializer with url request 24 | /// - Parameter urlRequest: the url request 25 | init(urlRequest: URLRequest) { 26 | self.urlRequest = urlRequest 27 | super.init() 28 | } 29 | 30 | /// Returns the received decision policy 31 | /// - Parameter policy: the navigation action policy 32 | func decisionHandler(_ policy: WKNavigationActionPolicy) { 33 | receivedPolicy = policy 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] This change requires a documentation update 13 | 14 | # Checklist: 15 | 16 | - [ ] My code follows the style guidelines of this project 17 | - [ ] I have performed a self-review of my code 18 | - [ ] I have commented my code, particularly in hard-to-understand areas 19 | - [ ] I have made corresponding changes to the documentation 20 | - [ ] My changes generate no new warnings 21 | - [ ] I have added tests that prove my fix is effective or that my feature works 22 | - [ ] New and existing unit tests pass locally with my changes 23 | - [ ] Any dependent changes have been merged and published in downstream modules 24 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/Digest+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Digest+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension Digest { 12 | 13 | /// Returns the digest as a byte array. 14 | var bytes: [UInt8] { 15 | Array(makeIterator()) 16 | } 17 | 18 | /// Returns the digest as data 19 | var data: Data { 20 | Data(bytes) 21 | } 22 | 23 | /// Returns the digest hex string value. 24 | var hex: String { 25 | bytes.map { String(format: "%02X", $0) }.joined().lowercased() 26 | } 27 | 28 | /// Returns the digest base64 encoded string value. 29 | var base64: String { 30 | data.base64EncodedString() 31 | } 32 | 33 | /// Returns the digest base64 URL encoded string value. 34 | var base64URL: String { 35 | base64 36 | .replacingOccurrences(of: "+", with: "-") 37 | .replacingOccurrences(of: "/", with: "_") 38 | .replacingOccurrences(of: "=", with: "") 39 | .trimmingCharacters(in: .whitespaces) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Code Fiesta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "OAuthKit", 8 | platforms: [ 9 | .iOS(.v26), 10 | .macOS(.v26), 11 | .tvOS(.v26), 12 | .visionOS(.v26), 13 | .watchOS(.v26) 14 | ], 15 | products: [ 16 | .library( 17 | name: "OAuthKit", 18 | targets: ["OAuthKit"]) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "OAuthKit", 23 | linkerSettings: [ 24 | .linkedFramework("CryptoKit"), 25 | .linkedFramework("LocalAuthentication", .when( 26 | platforms: [.iOS] 27 | )), 28 | .linkedFramework("Network"), 29 | .linkedFramework("Security") 30 | ] 31 | ), 32 | .testTarget( 33 | name: "OAuthKitTests", 34 | dependencies: ["OAuthKit"], 35 | resources: [.process("Resources")] 36 | ) 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAuthTestLAContext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuthTestLAContext.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | #if !os(tvOS) 8 | import Foundation 9 | import LocalAuthentication 10 | 11 | /// Provides an LAContext that can be safely used in tests that have set `.requireAuthenticationWithBiometricsOrCompanion` to true. 12 | class OAuthTestLAContext: LAContext { 13 | 14 | var canEvaluatePolicy: Bool = true 15 | var evaluatePolicyError: Error? 16 | 17 | /// Returns the localized reason for biometric or companion device authentication. 18 | override var localizedReason: String { 19 | set { } 20 | get { 21 | return "test keychain access" 22 | } 23 | } 24 | 25 | /// Overrides the evaluate policy to always succeed. 26 | /// - Parameters: 27 | /// - policy: the policy to evaludate 28 | /// - localizedReason: the reason for access 29 | /// - reply: the evaluation reply. 30 | override func evaluatePolicy(_ policy: LAPolicy, localizedReason: String?, reply: @escaping (Bool, Error?) -> Void) { 31 | reply(true, nil) 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-4.swift: -------------------------------------------------------------------------------- 1 | struct ContentView: View { 2 | 3 | @Environment(\.oauth) 4 | var oauth: OAuth 5 | 6 | @Environment(\.openWindow) 7 | var openWindow 8 | 9 | @Environment(\.dismissWindow) 10 | private var dismissWindow 11 | 12 | /// The main view body 13 | var body: some View { 14 | VStack { 15 | // Update the view based on the current oauth state 16 | } 17 | .onChange(of: oauth.state) { _, state in 18 | handle(state: state) 19 | } 20 | } 21 | 22 | /// Reacts to oauth state changes by opening or closing authorization windows. 23 | /// - Parameter state: the published state change 24 | private func handle(state: OAuth.State) { 25 | #if canImport(WebKit) 26 | switch state { 27 | case .empty, .error, .requestingAccessToken, .requestingDeviceCode: 28 | break 29 | case .authorizing, .receivedDeviceCode: 30 | openWindow(id: "oauth") 31 | case .authorized(_, _): 32 | dismissWindow(id: "oauth") 33 | } 34 | #endif 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | 16 | runs-on: macos-26 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install SwiftLint 21 | run: brew install swiftlint 22 | - name: Lint 23 | run: swiftlint lint --strict --quiet 24 | - name: Build 25 | run: swift build -v 26 | - name: Test 27 | run: swift test --enable-code-coverage 28 | - id: coverage 29 | uses: codefiesta/swift-coverage-action@0.0.4 30 | - name: badge 31 | # Only run the badge update if we are pushing to main 32 | if: github.ref == 'refs/heads/main' 33 | uses: schneegans/dynamic-badges-action@v1.7.0 34 | with: 35 | auth: ${{secrets.GIST_SECRET}} 36 | gistID: 87655b6e3c89b9198287b2fefbfa641f 37 | filename: oauthkit-coverage.json 38 | label: Coverage 39 | message: ${{steps.coverage.outputs.percentage}}% 40 | color: white 41 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/GrantType.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth/GrantType`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | A GrantType is used to define how an application obtains an access token from an OAuth 2.0 server. OAuthKit supports all major OAuth 2.0 grant types: 12 | - [Authorization Code](#Enumeration-Cases) 13 | - [Client Credentials](#Enumeration-Cases) 14 | - [Device Code](#Enumeration-Cases) 15 | - [Proof Key for Code Exchange (PKCE)](#Enumeration-Cases) 16 | - [Refresh Token](#Enumeration-Cases) 17 | 18 | 19 | > Important: For apps that don't have access to a web browser (like tvOS or watchOS), you'll need to start 20 | an ``OAuth/authorize(provider:grantType:)`` flow with the ``deviceCode`` grant Type. See for more details. 21 | 22 | > Tip: The [OAuth 2.0 Playground](https://www.oauth.com/playground/index.html) will help you understand the OAuth authorization flows and show each step of the process of obtaining an access token. 23 | 24 | ## Topics 25 | 26 | ### Associated Values 27 | 28 | - ``OAuth/PKCE`` 29 | - ``OAuth/DeviceCode`` 30 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/KeychainTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainTests.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | @testable import OAuthKit 9 | import Testing 10 | 11 | @Suite("Keychain Tests", .tags(.keychain)) 12 | final class KeychainTests { 13 | 14 | let keychain: Keychain 15 | 16 | deinit { 17 | keychain.clear() 18 | } 19 | 20 | /// Initializer. 21 | init() async throws { 22 | let tag: String = "oauthkit.test." + .secureRandom() 23 | keychain = .init(tag) 24 | } 25 | 26 | @Test("Storing keychain values") 27 | func whenStoring() async throws { 28 | let key = "Github" 29 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") 30 | 31 | let inserted = try! keychain.set(token, for: key) 32 | #expect(inserted == true) 33 | 34 | let found: OAuth.Token = try! keychain.get(key: key)! 35 | 36 | #expect(token.accessToken.isNotEmpty) 37 | #expect(token.accessToken == found.accessToken) 38 | #expect(token.expiresIn == found.expiresIn) 39 | #expect(token.scope == found.scope) 40 | #expect(token.type == found.type) 41 | 42 | let keys = keychain.keys 43 | debugPrint("🔐", keys) 44 | #expect(keys.count == 1) 45 | 46 | let deleted = keychain.delete(key: key) 47 | #expect(deleted == true) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/OAuthKit.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit`` 2 | @Metadata { 3 | @PageColor(purple) 4 | @Available(iOS, introduced: "18.0") 5 | @Available(macOS, introduced: "15.0") 6 | @Available(tvOS, introduced: "18.0") 7 | @Available(visionOS, introduced: "2.0") 8 | @Available(watchOS, introduced: "11.0") 9 | } 10 | 11 | A modern and observable framework for implementing OAuth 2.0 authorization flows. 12 | 13 | ## Overview 14 | 15 | 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. 16 | 17 | 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. 18 | 19 | ### Articles 20 | 21 | @Links(visualStyle: detailedGrid) { 22 | - 23 | - 24 | } 25 | 26 | ### Sample code 27 | 28 | @Links(visualStyle: detailedGrid) { 29 | - 30 | } 31 | 32 | ### Tutorials 33 | 34 | @Links(visualStyle: list) { 35 | - 36 | } 37 | 38 | ## Topics 39 | 40 | ### Essentials 41 | 42 | - 43 | - 44 | - ``OAuth`` 45 | - ``OAuth/Provider`` 46 | - ``OAuth/GrantType`` 47 | 48 | ### State Observability 49 | - ``OAuth/State`` 50 | - ``OAuth/state`` 51 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/OAuth.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | You can create an observable OAuth object using the ``OAuth/init(_:options:)`` or ``OAuth/init(providers:options:)`` initializers, or 12 | you can access a default OAuth object via SwiftUI ``SwiftUICore/EnvironmentValues`` via the following: 13 | 14 | ```swift 15 | @Environment(\.oauth) 16 | var oauth: OAuth 17 | ``` 18 | 19 | An OAuth object can also be highly customized when passed a dictionary of ``Option`` values into it's iniitializers. 20 | 21 | ```swift 22 | let options: [OAuth.Option: Any] = [ 23 | .applicationTag: "com.bundle.Idenfitier", 24 | .autoRefresh: true, 25 | .requireAuthenticationWithBiometricsOrCompanion: true, 26 | .useNonPersistentWebDataStore: true 27 | ] 28 | let oauth: OAuth = .init(.module, options: options) 29 | ``` 30 | 31 | - SeeAlso: 32 | [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) 33 | 34 | 35 | ## Topics 36 | 37 | ### Creating an Observable OAuth 38 | 39 | - ``init(_:options:)`` 40 | - ``init(providers:options:)`` 41 | 42 | ### Starting an authorization flow 43 | 44 | - ``authorize(provider:grantType:)`` 45 | - ``OAuth/GrantType`` 46 | 47 | ### OAuth State Tracking 48 | 49 | - ``state`` 50 | - ``providers`` 51 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+PKCE.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth+PKCE.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | /// The default code challenge method (SHA-256 hash). 11 | private let defaultCodeChallengeMethod = "S256" 12 | 13 | extension OAuth { 14 | 15 | /// Provides a structure for OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE). 16 | public struct PKCE: Equatable, Sendable { 17 | 18 | /// A cryptographically random string between 43 and 128 characters long. 19 | /// See RFC 7636 Standard [PKCE Code Verifier](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1). 20 | public let codeVerifier: String 21 | 22 | /// A code challenge derived from the codeVerifier that is a Base 64 URL encoded string from it's SHA-256 digest. 23 | /// See RFC 7636 Standard [PKCE Code Challenge](https://datatracker.ietf.org/doc/html/rfc7636#section-4.2). 24 | public let codeChallenge: String 25 | 26 | /// The PKCE state code. 27 | public let state: String 28 | 29 | /// Returns the code challenge method. Currently only supports [SHA-256 hash](https://en.wikipedia.org/wiki/SHA-2). 30 | public let codeChallengeMethod: String 31 | 32 | /// Initializer. 33 | public init() { 34 | codeVerifier = .secureRandom() 35 | codeChallenge = codeVerifier.sha256.base64URL 36 | codeChallengeMethod = defaultCodeChallengeMethod 37 | state = .secureRandom(count: 16).base64URL 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+Authorization.swift: -------------------------------------------------------------------------------- 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 | /// Creates an authorization that can be stored inside the keychain. 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 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/Data+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension Data { 12 | 13 | /// Returns the SHA-256 Digest of this data block. 14 | var sha256: SHA256.Digest { 15 | SHA256.hash(data: self) 16 | } 17 | 18 | /// Returns the SHA-512 Digest of this data block. 19 | var sha512: SHA512.Digest { 20 | SHA512.hash(data: self) 21 | } 22 | 23 | /// Encodes the data as a Base64 URL encoded string. 24 | /// Base64 URL encoding is a variant of Base64 encoding that is specifically designed to be safe for use in URLs and filenames. 25 | /// 26 | /// Key Differences Between Base64 and Base64 URL Encoding: 27 | /// 1. **Character Set** 28 | /// * `Base64`: Uses `+` and `/` 29 | /// * `Base64URL`: Uses `-` and `_` 30 | /// 2. **Padding** 31 | /// * `Base64`: May include `=` padding to ensure the encoded string length is a multiple of 4. 32 | /// * `Base64URL`: Omits padding characters. 33 | var base64URLEncoded: String { 34 | base64EncodedString() 35 | .replacingOccurrences(of: "+", with: "-") 36 | .replacingOccurrences(of: "/", with: "_") 37 | .replacingOccurrences(of: "=", with: "") 38 | .trimmingCharacters(in: .whitespaces) 39 | } 40 | 41 | /// Generates secure random bytes for the specified byte counts. 42 | /// - Parameter count: The number of bytes to generate. 43 | /// - Returns: an array of cryptographically secure random bytes 44 | static func secureRandom(count: Int = 32) -> Data { 45 | var bytes = [UInt8](repeating: 0, count: count) 46 | _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) 47 | return Data(bytes: &bytes, count: bytes.count) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/State.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth/State`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | An example of observing the ``state`` property in SwiftUI: 12 | 13 | ```swift 14 | struct ContentView: View { 15 | 16 | @Environment(\.oauth) 17 | var oauth: OAuth 18 | 19 | var body: some View { 20 | VStack { 21 | switch oauth.state { 22 | case .empty: 23 | providerList 24 | case .authorizing(let provider, let grantType): 25 | Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") 26 | case .requestingAccessToken(let provider): 27 | Text("Requesting Access Token [\(provider.id)]") 28 | case .requestingDeviceCode(let provider): 29 | Text("Requesting Device Code [\(provider.id)]") 30 | case .authorized(let provider, _): 31 | Button("Authorized [\(provider.id)]") { 32 | oauth.clear() 33 | } 34 | case .receivedDeviceCode(_, let deviceCode): 35 | Text("To login, visit") 36 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 37 | .foregroundStyle(.blue) 38 | Text("and enter the following code:") 39 | Text(deviceCode.userCode) 40 | case .error(let provider, let error): 41 | Text("Error [\(provider.id)]: \(error.localizedDescription)") 42 | } 43 | } 44 | .onChange(of: oauth.state) { oldState, newState in 45 | handle(state: newState) 46 | } 47 | } 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Network/NetworkMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkMonitor.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Network 9 | import Observation 10 | 11 | /// An `Observable` type that publishes network reachability information. 12 | @MainActor 13 | @Observable 14 | public final class NetworkMonitor: Sendable { 15 | 16 | // The shared singleton network monitor. 17 | public static let shared: NetworkMonitor = .init() 18 | 19 | @ObservationIgnored 20 | private let pathMonitor = NWPathMonitor() 21 | 22 | /// Flag indicating if monitoring is currently active or not. 23 | public private(set) var isMonitoring = false 24 | 25 | /// Returns true if the network has an available wifi interface. 26 | public var onWifi = false 27 | /// Returns true if the network has an available cellular interface. 28 | public var onCellular = false 29 | /// Returns true if the network has an wired ethernet interface. 30 | public var onWiredEthernet = false 31 | 32 | /// Returns true if the network is online with any available interface. 33 | public var isOnline: Bool { 34 | onWifi || onCellular || onWiredEthernet 35 | } 36 | 37 | /// Initializer. 38 | private init() { } 39 | 40 | /// Starts the network monitor (conforms to AsyncSequence). 41 | public func start() async { 42 | guard !isMonitoring else { return } 43 | isMonitoring.toggle() 44 | for await path in pathMonitor { 45 | handle(path: path) 46 | } 47 | } 48 | 49 | /// Handles the snapshot view of the network path state. 50 | /// - Parameter path: the snapshot view of the network path state 51 | private func handle(path: NWPath) { 52 | onWifi = path.usesInterfaceType(.wifi) 53 | onCellular = path.usesInterfaceType(.cellular) 54 | onWiredEthernet = path.usesInterfaceType(.wiredEthernet) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/docc.yml: -------------------------------------------------------------------------------- 1 | # Generates and Deploys DocC documentation to Github pages 2 | # See: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 3 | name: DocC 4 | 5 | on: 6 | push: 7 | branches: [ "main" ] 8 | 9 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | # Allow one concurrent deployment 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | 22 | # Single deploy job since we're just deploying 23 | docc: 24 | 25 | environment: 26 | # Must be set to this for deploying to GitHub Pages 27 | name: github-pages 28 | url: ${{steps.deployment.outputs.page_url}} 29 | 30 | runs-on: macos-26 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Add Plugin Dependency 35 | run: swift package add-dependency "https://github.com/apple/swift-docc-plugin" --branch main 36 | - name: Generate Documentation 37 | run: | 38 | swift package \ 39 | --allow-writing-to-package-directory \ 40 | --allow-writing-to-directory ${{github.workspace}}/docs \ 41 | generate-documentation \ 42 | --target OAuthKit \ 43 | --transform-for-static-hosting \ 44 | --hosting-base-path OAuthKit \ 45 | --output-path ${{github.workspace}}/docs \ 46 | --source-service github \ 47 | --source-service-base-url https://github.com/codefiesta/OAuthKit/blob/main \ 48 | --checkout-path ${{github.workspace}}; 49 | - name: Update Index 50 | run: echo "" > ${{github.workspace}}/docs/index.html; 51 | - name: Upload Pages Artifact 52 | uses: actions/upload-pages-artifact@v3 53 | with: 54 | path: 'docs' 55 | - name: Deploy to GitHub Pages 56 | uses: actions/deploy-pages@v4 57 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAuth+Monitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth+Monitor.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | @testable import OAuthKit 10 | 11 | extension OAuth { 12 | 13 | /// Provides a testing utility to stream an oauth state until it's received an authorization. 14 | /// This is necessary because a unit test can potentially die as an asynchronous request is received that 15 | /// inserts an authorization record into the keychain. This allows us to keep the keychain clean and not get littered with test records. 16 | @MainActor 17 | class Monitor { 18 | 19 | typealias OAuthStateAsyncStream = AsyncStream 20 | 21 | private let oauth: OAuth 22 | 23 | var continuation: OAuthStateAsyncStream.Continuation? 24 | 25 | lazy var stream: OAuthStateAsyncStream = { 26 | AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in 27 | self.continuation = continuation 28 | waitForNextValue() 29 | } 30 | }() 31 | 32 | /// Initialzation. 33 | /// - Parameter oauth: the oauth state to stream 34 | init(oauth: OAuth) { 35 | self.oauth = oauth 36 | } 37 | 38 | deinit { 39 | continuation?.finish() 40 | } 41 | 42 | /// Waits for the next state value to be received. This will continue until we've received an `.authorized` state. 43 | private func waitForNextValue() { 44 | Task { 45 | let state = oauth.state 46 | continuation?.yield(state) 47 | switch state { 48 | case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: 49 | waitForNextValue() 50 | case .authorized(_, _): 51 | continuation?.finish() 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Views/OAWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAWebView.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | #if canImport(WebKit) 9 | import SwiftUI 10 | import WebKit 11 | 12 | /// A UIViewRepresentable / NSViewRepresentable wrapper type that coordinates 13 | /// oauth authorization flows inside a `WKWebView`. 14 | @MainActor 15 | public struct OAWebView { 16 | 17 | let oauth: OAuth 18 | let view: WKWebView 19 | 20 | /// Initializer with the speciifed oauth object. 21 | /// - Parameter oauth: the oauth object to use 22 | public init(oauth: OAuth) { 23 | self.oauth = oauth 24 | let configuration = WKWebViewConfiguration() 25 | configuration.websiteDataStore = oauth.useNonPersistentWebDataStore ? WKWebsiteDataStore.nonPersistent() : WKWebsiteDataStore.default() 26 | self.view = WKWebView(frame: .zero, configuration: configuration) 27 | } 28 | 29 | public func makeWebView(context: Context) -> WKWebView { 30 | view.navigationDelegate = context.coordinator 31 | view.allowsLinkPreview = true 32 | return view 33 | } 34 | 35 | public func makeCoordinator() -> OAWebViewCoordinator { 36 | OAWebViewCoordinator(self) 37 | } 38 | } 39 | #endif 40 | 41 | 42 | #if os(iOS) || os(visionOS) 43 | 44 | extension OAWebView: UIViewRepresentable { 45 | 46 | public func makeUIView(context: Context) -> WKWebView { 47 | makeWebView(context: context) 48 | } 49 | 50 | public func updateUIView(_ uiView: WKWebView, context: Context) { 51 | context.coordinator.update(state: oauth.state) 52 | } 53 | } 54 | 55 | #elseif os(macOS) 56 | 57 | extension OAWebView: NSViewRepresentable { 58 | 59 | public func makeNSView(context: Context) -> some NSView { 60 | makeWebView(context: context) 61 | } 62 | 63 | public func updateNSView(_ nsView: NSViewType, context: Context) { 64 | context.coordinator.update(state: oauth.state) 65 | } 66 | } 67 | 68 | #endif 69 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+State.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth+State.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | extension OAuth { 11 | 12 | /// Holds the OAuth state that is published to subscribers via the ``state`` property. 13 | public enum State: Equatable, Sendable { 14 | 15 | /// The state is empty and no authorizations or tokens have been issued. 16 | case empty 17 | 18 | /// The OAuth authorization workflow has been started for the specifed provider and grant type. 19 | /// - Parameters: 20 | /// - Provider: the oauth provider 21 | /// - GrantType: the grant type 22 | case authorizing(Provider, GrantType) 23 | 24 | /// An access token is being requested for the specifed provider. 25 | /// - Parameters: 26 | /// - Provider: the oauth provider 27 | case requestingAccessToken(Provider) 28 | 29 | /// A device code is being requested for the specifed provider. 30 | /// - Parameters: 31 | /// - Provider: the oauth provider 32 | case requestingDeviceCode(Provider) 33 | 34 | /// A device code has been received by the specified provider and it's access token endpoint is 35 | /// actively being polled at the device code's interval until it expires, or until an error or access token is returned. 36 | /// - Parameters: 37 | /// - Provider: the oauth provider 38 | /// - DeviceCode: the device code 39 | case receivedDeviceCode(Provider, DeviceCode) 40 | 41 | /// An authorization has been granted. 42 | /// - Parameters: 43 | /// - Provider: the oauth provider 44 | /// - Authorization: the oauth authorization 45 | case authorized(Provider, Authorization) 46 | 47 | /// An error has occurred during an authorization flow for the specified provider. 48 | /// - Parameters: 49 | /// - Provider: the oauth provider 50 | /// - OAError: the error information 51 | case error(Provider, OAError) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-2.swift: -------------------------------------------------------------------------------- 1 | struct ContentView: View { 2 | 3 | @Environment(\.oauth) 4 | var oauth: OAuth 5 | 6 | @Environment(\.openWindow) 7 | var openWindow 8 | 9 | @Environment(\.dismissWindow) 10 | private var dismissWindow 11 | 12 | /// Displays a list of oauth providers. 13 | var providerList: some View { 14 | List(oauth.providers) { provider in 15 | Button(provider.id) { 16 | // Start an authorization flow 17 | } 18 | } 19 | } 20 | 21 | /// The main view body 22 | var body: some View { 23 | VStack { 24 | // Update the view based on the current oauth state 25 | switch oauth.state { 26 | case .empty: 27 | providerList 28 | case .authorizing(let provider, let grantType): 29 | Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") 30 | case .requestingAccessToken(let provider): 31 | Text("Requesting Access Token [\(provider.id)]") 32 | case .requestingDeviceCode(let provider): 33 | Text("Requesting Device Code [\(provider.id)]") 34 | case .authorized(let provider, _): 35 | Button("Authorized [\(provider.id)]") { 36 | oauth.clear() 37 | } 38 | case .receivedDeviceCode(_, let deviceCode): 39 | Text("To login, visit") 40 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 41 | .foregroundStyle(.blue) 42 | Text("and enter the following code:") 43 | Text(deviceCode.userCode) 44 | .padding() 45 | .border(Color.primary) 46 | .font(.title) 47 | case .error(let provider, let error): 48 | Text("Error [\(provider.id)]: \(error.localizedDescription)") 49 | } 50 | } 51 | .onChange(of: oauth.state) { _, state in 52 | // Handle state change 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+GrantType.swift: -------------------------------------------------------------------------------- 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 | public enum GrantType: Equatable, Sendable { 14 | 15 | /// The OAuth 2.0 authorization code workflow. 16 | /// See RFC 6749 Standard [OAuth 2.0 Authorization Code Grant Type](https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.1) 17 | /// - Parameters: 18 | /// - String: the state verification string used to prevent CSRF attacks 19 | case authorizationCode(String) 20 | 21 | /// The OAuth 2.0 client credentials grant. 22 | /// See RFC 6749 Standard [OAuth 2.0 Client Credentials Code Grant Type](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4) 23 | case clientCredentials 24 | 25 | /// The OAuth 2.0 device authorization grant. 26 | /// See RFC 6749 Standard [OAuth 2.0 Device Code Grant Type](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4) 27 | case deviceCode 28 | 29 | /// The OAuth 2.0 Proof Key for Code Exchange is an extension to the Authorization Code 30 | /// flow to prevent CSRF and authorization code injection attacks. See RFC 7636 Standard [OAuth 2.0 Proof Key for Code Exchange](https://datatracker.ietf.org/doc/html/rfc7636) 31 | /// - Parameters: 32 | /// - PKCE: the PKCE data 33 | case pkce(PKCE) 34 | 35 | /// The OAuth 2.0 Refresh Token grant type is used by clients to exchange a refresh token for an access token when the access token has expired. 36 | /// See RFC 6749 Standard [OAuth 2.0 Refresh Token](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5) 37 | case refreshToken 38 | 39 | /// The raw string value for a grant type. 40 | public var rawValue: String { 41 | switch self { 42 | case .authorizationCode: 43 | "authorization_code" 44 | case .clientCredentials: 45 | "client_credentials" 46 | case .deviceCode: 47 | "device_code" 48 | case .pkce: 49 | "pkce" 50 | case .refreshToken: 51 | "refresh_token" 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+Option.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth+Option.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | extension OAuth { 11 | 12 | /// Keys and values used to specify loading or runtime options. 13 | public struct Option: Hashable, RawRepresentable, Sendable { 14 | 15 | /// The option raw value. 16 | public var rawValue: String 17 | 18 | /// Initializer 19 | /// - Parameter rawValue: the option raw value 20 | public init(rawValue: String) { 21 | self.rawValue = rawValue 22 | } 23 | } 24 | } 25 | 26 | public extension OAuth.Option { 27 | 28 | /// A key used for custom application identifiers to improve token tagging. 29 | static let applicationTag: OAuth.Option = .init(rawValue: "applicationTag") 30 | 31 | /// A key used to specify whether tokens should be automatically refreshed or not. 32 | static let autoRefresh: OAuth.Option = .init(rawValue: "autoRefresh") 33 | 34 | /// A key used for providing a custom local authentication object. 35 | static let localAuthentication: OAuth.Option = .init(rawValue: "localAuthentication") 36 | 37 | /// A key used for determining if the keychain should be protected with biometrics until successful local authentication. 38 | /// If set to true, the device owner will need to be authenticated by biometry or a companion device before the keychain items can be accessed. 39 | /// Important: developers should set the requireAuthenticationWithBiometricsOrCompanionReason that will be eventually displayed in the authentication dialog. 40 | static let requireAuthenticationWithBiometricsOrCompanion: OAuth.Option = . init(rawValue: "requireAuthenticationWithBiometricsOrCompanion") 41 | 42 | /// A key used for providing a custom url session. 43 | static let urlSession: OAuth.Option = .init(rawValue: "urlSession") 44 | 45 | /// A key used for setting the WKWebsiteDataStore to `nonPersistent()` in the OAWebView. 46 | /// This is disabled by default, but this can be turned on to allow developers to use an ephemeral webkit datastore 47 | /// that effectively implements private browsing and forces a new login attempt every time an authorization flow is started. 48 | static let useNonPersistentWebDataStore: OAuth.Option = .init(rawValue: "useNonPersistentWebDataStore") 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Tutorials/FlowsStates.tutorial: -------------------------------------------------------------------------------- 1 | @Tutorial(time: 7) { 2 | @Intro(title: "OAuth Flows and States") { 3 | This tutorial guides you through starting authorization flows, and observing ``OAuth/state``. 4 | 5 | @Image(source: gettingStarted-card.jpg, alt: "OAuthKit") 6 | } 7 | 8 | @Section(title: "Observing OAuth Objects") { 9 | @ContentAndMedia { 10 | This section walks you through an example of how to use an observable ``OAuth`` object. 11 | } 12 | 13 | @Steps { 14 | @Step { 15 | Declare a default ``OAuth`` instance via the @Environment property wrapper in SwiftUI that can be accessed throughout your app. 16 | 17 | > Tip: For details of how to create and configure an ``OAuth`` instance, see 18 | @Code(name: "OAuthApp.swift", file: flows-states-step-1.swift) 19 | } 20 | 21 | @Step { 22 | Observe the ``OAuth/state`` property for changes in your View. 23 | @Code(name: "ContentView.swift", file: flows-states-step-2.swift) 24 | } 25 | 26 | @Step { 27 | Start an flow for a `Provider` when a user taps a `Provider` in the list. 28 | 29 | > 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. 30 | @Code(name: "ContentView.swift", file: flows-states-step-3.swift) 31 | } 32 | 33 | @Step { 34 | Open the authorization window when the ``OAuth/state`` reaches an ``OAuth/State/authorizing(_:_:)`` state. 35 | @Code(name: "ContentView.swift", file: flows-states-step-4.swift) 36 | } 37 | 38 | @Step { 39 | Once the ``OAuth/Provider`` has been authorized, the ``OAuth/state`` will reach an ``OAuth/State/authorized(_:_:)`` state can you can dismiss the authorization window. 40 | @Code(name: "ContentView.swift", file: flows-states-step-4.swift) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Extensions/Provider.md: -------------------------------------------------------------------------------- 1 | # ``OAuthKit/OAuth/Provider`` 2 | @Metadata { 3 | @Available(iOS, introduced: "18.0") 4 | @Available(macOS, introduced: "15.0") 5 | @Available(tvOS, introduced: "18.0") 6 | @Available(visionOS, introduced: "2.0") 7 | @Available(watchOS, introduced: "11.0") 8 | } 9 | 10 | ## Overview 11 | The Provider holds the configuration data that is used for communicating with an OAuth 2.0 server. The easiest way to configure OAuthKit is to simply drop an `oauth.json` file into your main bundle and it will get automatically loaded into your swift application and available as an ``SwiftUICore/EnvironmentValues/oauth`` property wrapper. You can find an example `oauth.json` file [here](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). 12 | ```json 13 | [ 14 | { 15 | "id": "GitHub", 16 | "authorizationURL": "https://github.com/login/oauth/authorize", 17 | "accessTokenURL": "https://github.com/login/oauth/access_token", 18 | "deviceCodeURL": "https://github.com/login/device/code", 19 | "clientID": "CLIENT_ID", 20 | "clientSecret": "CLIENT_SECRET", 21 | "redirectURI": "oauthkit://callback", 22 | "scope": [ 23 | "user", 24 | "repo", 25 | "openid" 26 | ], 27 | "debug": true 28 | } 29 | ] 30 | ``` 31 | > Warning: It's highly recommended that developers only use `oauth.json` files during development and don't include them in publicly distributed applications. It is possible for someone to [inspect and reverse engineer](https://www.nowsecure.com/blog/2021/09/08/basics-of-reverse-engineering-ios-mobile-apps/) the contents of your app and look at any files inside your app bundle which means you could potentially expose any confidential values contained in this file. It's recommended to build OAuth Providers Programmatically via your CI Build Pipeline. Most continuous integration and delivery platforms have the ability to generate source code during build workflows that can get compiled into Swift byte code. It's should be feasible to write a step in the CI pipeline that generates a .swift file that provides access to a list of OAuth.Provider objects that have their confidential values set from the secure CI platform secret keys. This swift code can then compiled into the application as byte code. In practical terms, the security and obfuscation inherent in compiled languages make extracting confidential values difficult (but not impossible). 32 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+Token.swift: -------------------------------------------------------------------------------- 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 | public struct Token: Codable, Equatable, Sendable { 14 | 15 | /// The access token string as issued by the authorization server. 16 | public let accessToken: String 17 | 18 | /// If the access token will expire, then this can be used to obtain another access token. 19 | public let refreshToken: String? 20 | 21 | /// If the access token expires, this is the duration of time the access token is granted for (in seconds). 22 | public let expiresIn: Int? 23 | 24 | /// If the scope the user granted is identical to the scope the app requested, this parameter is optional. 25 | /// If the granted scope is different from the requested scope, such as if the user modified the scope, then this parameter is required. 26 | public let scope: String? 27 | 28 | /// The type of token this is, typically just the string “Bearer”. 29 | public let type: String 30 | 31 | /// The OpenID Connect issued by the authorization server. 32 | /// This token is included if the authorization server supports OpenID connect and the scope included `openid` 33 | public let openIDToken: String? 34 | 35 | /// Common Initializer. 36 | /// - Parameters: 37 | /// - accessToken: the access token string 38 | /// - refreshToken: the refresh token 39 | /// - expiresIn: the expiration time in secods 40 | /// - scope: the scope returned from the authorization server 41 | /// - type: the token type 42 | /// - openIDToken: the OpenID Connect token 43 | public init(accessToken: String, refreshToken: String?, expiresIn: Int?, scope: String?, type: String, openIDToken: String? = nil) { 44 | self.accessToken = accessToken 45 | self.refreshToken = refreshToken 46 | self.expiresIn = expiresIn 47 | self.scope = scope 48 | self.type = type 49 | self.openIDToken = openIDToken 50 | } 51 | 52 | enum CodingKeys: String, CodingKey { 53 | case accessToken = "access_token" 54 | case refreshToken = "refresh_token" 55 | case expiresIn = "expires_in" 56 | case type = "token_type" 57 | case scope 58 | case openIDToken = "id_token" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import CryptoKit 9 | import Foundation 10 | 11 | extension String { 12 | 13 | /// Denotes an empty string literal. 14 | static let empty = "" 15 | 16 | /// Returns the SHA-256 Digest for this string instance 17 | var sha256: SHA256.Digest { 18 | Data(self.utf8).sha256 19 | } 20 | 21 | /// Returns the SHA-512 Digest for this string instance 22 | var sha512: SHA512.Digest { 23 | Data(self.utf8).sha512 24 | } 25 | 26 | /// Encodes the string as a Base64 encoded string. 27 | var base64: String { 28 | Data(self.utf8).base64EncodedString() 29 | } 30 | 31 | /// Encodes the string as a Base64 URL encoded string. 32 | /// Base64 URL encoding is a variant of Base64 encoding that is specifically designed to be safe for use in URLs and filenames. 33 | /// 34 | /// Key Differences Between Base64 and Base64 URL Encoding: 35 | /// 1. **Character Set** 36 | /// * `Base64`: Uses `+` and `/` 37 | /// * `Base64URL`: Uses `-` and `_` 38 | /// 2. **Padding** 39 | /// * `Base64`: May include `=` padding to ensure the encoded string length is a multiple of 4. 40 | /// * `Base64URL`: Omits padding characters. 41 | var base64URL: String { 42 | base64 43 | .replacingOccurrences(of: "+", with: "-") 44 | .replacingOccurrences(of: "/", with: "_") 45 | .replacingOccurrences(of: "=", with: "") 46 | .trimmingCharacters(in: .whitespaces) 47 | } 48 | 49 | /// Returns the decoded value of this Base64 encoded string. 50 | var base64Decoded: String { 51 | guard let data = Data(base64Encoded: Data(self.utf8)) else { return self } 52 | return String(data: data, encoding: .utf8) ?? self 53 | } 54 | 55 | /// Returns the decoded value of this Base64 URL encoded string. 56 | var base64URLDecoded: String { 57 | var result = self 58 | .replacingOccurrences(of: "-", with: "+") 59 | .replacingOccurrences(of: "_", with: "/") 60 | while result.count % 4 != 0 { 61 | result += "=" 62 | } 63 | return result.base64Decoded 64 | } 65 | 66 | /// Generates a cryptographically secure random Base 64 URL encoded string. 67 | /// - Parameter count: the byte count 68 | /// - Returns: a cryptographically secure random Base 64 URL encoded string 69 | static func secureRandom(count: Int = 32) -> String { 70 | let data: Data = .secureRandom(count: count) 71 | return data.sha256.base64URL 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Resources/tutorial-code-files/flows-states-step-3.swift: -------------------------------------------------------------------------------- 1 | struct ContentView: View { 2 | 3 | @Environment(\.oauth) 4 | var oauth: OAuth 5 | 6 | @Environment(\.openWindow) 7 | var openWindow 8 | 9 | @Environment(\.dismissWindow) 10 | private var dismissWindow 11 | 12 | /// Displays a list of oauth providers. 13 | var providerList: some View { 14 | List(oauth.providers) { provider in 15 | Button(provider.id) { 16 | authorize(provider: provider) 17 | } 18 | } 19 | } 20 | 21 | /// Starts the authorization process for the specified provider. 22 | /// - Parameter provider: the provider to begin authorization for 23 | private func authorize(provider: OAuth.Provider) { 24 | #if canImport(WebKit) 25 | // Use the PKCE grantType for iOS, macOS, visionOS 26 | let grantType: OAuth.GrantType = .pkce(.init()) 27 | #else 28 | // Use the Device Code grantType for tvOS, watchOS 29 | let grantType: OAuth.GrantType = .deviceCode 30 | #endif 31 | // Start the authorization flow 32 | oauth.authorize(provider: provider, grantType: grantType) 33 | } 34 | 35 | /// The main view body 36 | var body: some View { 37 | VStack { 38 | // Update the view based on the current oauth state 39 | switch oauth.state { 40 | case .empty: 41 | providerList 42 | case .authorizing(let provider, let grantType): 43 | Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") 44 | case .requestingAccessToken(let provider): 45 | Text("Requesting Access Token [\(provider.id)]") 46 | case .requestingDeviceCode(let provider): 47 | Text("Requesting Device Code [\(provider.id)]") 48 | case .authorized(let provider, _): 49 | Button("Authorized [\(provider.id)]") { 50 | oauth.clear() 51 | } 52 | case .receivedDeviceCode(_, let deviceCode): 53 | Text("To login, visit") 54 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 55 | .foregroundStyle(.blue) 56 | Text("and enter the following code:") 57 | Text(deviceCode.userCode) 58 | .padding() 59 | .border(Color.primary) 60 | .font(.title) 61 | case .error(let provider, let error): 62 | Text("Error [\(provider.id)]: \(error.localizedDescription)") 63 | } 64 | } 65 | .onChange(of: oauth.state) { _, state in 66 | // Handle state change 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/CodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CodeableTests.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | @testable import OAuthKit 10 | import Testing 11 | 12 | @Suite("Codable Tests") 13 | struct CodableTests { 14 | 15 | private let encoder: JSONEncoder = .init() 16 | private let decoder: JSONDecoder = .init() 17 | 18 | @Test("Encoding and Decoding Providers") 19 | func whenEncodingDecodingProviders() async throws { 20 | 21 | let provider: OAuth.Provider = .init(id: "GitHub", 22 | authorizationURL: URL(string: "https://github.com/login/oauth/authorize")!, 23 | accessTokenURL: URL(string: "https://github.com/login/oauth/access_token")!, 24 | clientID: "CLIENT_ID", 25 | clientSecret: "CLIENT_SECRET", 26 | scope: ["email"], 27 | 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", 28 | debug: true) 29 | 30 | let data = try encoder.encode(provider) 31 | let decoded: OAuth.Provider = try decoder.decode(OAuth.Provider.self, from: data) 32 | #expect(provider == decoded) 33 | } 34 | 35 | @Test("Encoding and Decoding Tokens") 36 | func whenEncodingDecodingTokens() async throws { 37 | 38 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "openid", type: "bearer") 39 | 40 | let data = try encoder.encode(token) 41 | let decoded: OAuth.Token = try decoder.decode(OAuth.Token.self, from: data) 42 | #expect(token == decoded) 43 | } 44 | 45 | @Test("Encoding and Decoding Device Codes") 46 | func whenDecodingDeviceCodes() async throws { 47 | 48 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: "ABC-XYZ", verificationUri: "https://example.com/device", expiresIn: 1800, interval: 5) 49 | 50 | let data = try encoder.encode(deviceCode) 51 | let decoded: OAuth.DeviceCode = try decoder.decode(OAuth.DeviceCode.self, from: data) 52 | #expect(deviceCode.deviceCode == decoded.deviceCode) 53 | #expect(deviceCode.userCode == decoded.userCode) 54 | #expect(deviceCode.verificationUri == decoded.verificationUri) 55 | #expect(deviceCode.verificationUriComplete == decoded.verificationUriComplete) 56 | #expect(deviceCode.expiresIn == decoded.expiresIn) 57 | #expect(deviceCode.isExpired == false) 58 | #expect(deviceCode.expiration != nil) 59 | #expect(deviceCode.interval == decoded.interval) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run CodeQL analysis. 2 | name: CodeQL 3 | 4 | on: 5 | push: 6 | branches: [ "main" ] 7 | pull_request: 8 | branches: [ "main" ] 9 | types: [opened, synchronize, reopened, ready_for_review] 10 | schedule: 11 | - cron: '30 7 * * 3' 12 | 13 | jobs: 14 | 15 | analyze: 16 | 17 | # Only run this action if the PR isn't a draft and it is labled as a `security` PR 18 | if: github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'security') 19 | 20 | name: Analyze (${{ matrix.language }}) 21 | # Runner size impacts CodeQL analysis time. To learn more, please see: 22 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 23 | # - https://gh.io/supported-runners-and-hardware-resources 24 | # - https://gh.io/using-larger-runners (GitHub.com only) 25 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 26 | 27 | runs-on: macos-26 28 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 29 | permissions: 30 | # required for all workflows 31 | security-events: write 32 | 33 | # required to fetch internal or private CodeQL packs 34 | packages: read 35 | 36 | # only required for workflows in private repositories 37 | actions: read 38 | contents: read 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | include: 44 | - language: swift 45 | build-mode: autobuild 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | - name: Initialize CodeQL 51 | uses: github/codeql-action/init@v3 52 | with: 53 | languages: ${{ matrix.language }} 54 | build-mode: ${{ matrix.build-mode }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | 59 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 60 | # queries: security-extended,security-and-quality 61 | 62 | # If the analyze step fails for one of the languages you are analyzing with 63 | # "We were unable to automatically build your code", modify the matrix above 64 | # to set the build mode to "manual" for that language. Then modify this step 65 | # to build your code. 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 68 | - if: matrix.build-mode == 'manual' 69 | shell: bash 70 | run: | 71 | echo 'If you are using a "manual" build mode for one or more of the' \ 72 | 'languages you are analyzing, replace this with the commands to build' \ 73 | 'your code, for example:' 74 | echo ' make bootstrap' 75 | echo ' make release' 76 | exit 1 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v3 80 | with: 81 | category: "/language:${{matrix.language}}" 82 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing Guidelines 2 | -------------------------------------------------- 3 | 4 | This document provides general guidelines about how to contribute to the project. Keep in mind these important things before you start contributing. 5 | 6 | ### Reporting issues 7 | 8 | * Use [github issues](https://github.com/codefiesta/OAuthKit/issues) to report a bug. 9 | * Before creating a new issue: 10 | * Make sure you are using the [latest release](https://github.com/codefiesta/OAuthKit/releases). 11 | * Check if the issue was [already reported or fixed](https://github.com/codefiesta/OAuthKit/issues?utf8=%E2%9C%93&q=is%3Aissue). Notice that it may not be released yet. 12 | * If you found a match add a brief comment "I have the same problem" or "+1". This helps prioritize the issues addressing the most common and critical first. If possible add additional information to help us reproduce and fix the issue. Please use your best judgement. 13 | * Reporting issues: 14 | * Please include the following information to help maintainers to fix the problem faster: 15 | * Xcode version you are using. 16 | * Platform and version you are targeting. 17 | * Full Xcode console output of stack trace or code compilation error. 18 | * Any other additional detail you think it would be useful to understand and solve the problem. 19 | 20 | 21 | ### Pull requests 22 | 23 | The easiest way to start contributing is searching open issues by `help wanted` tag. 24 | 25 | * Add test coverage to the feature or fix. We only accept new feature pull requests that have related test coverage. This allows us to keep the library stable as we move forward. 26 | * Remember to document the new feature. We do not accept new feature pull requests without its associated documentation. 27 | * In case of a new feature please update the example project showing the feature. 28 | * Please only one fix or feature per pull request. This will increase the chances your feature will be merged. 29 | 30 | 31 | ###### Suggested git workflow to contribute 32 | 33 | 1. Fork the OAuthKit repository. 34 | 2. Clone your forked project into your developer machine: `git clone git@github.com:/OAuthKit.git` 35 | 3. Add the original project repo as upstream repository in your forked project: `git remote add upstream git@github.com:codefiesta/OAuthKit.git` 36 | 4. Before starting a new feature make sure your forked master branch is synchronized upstream master branch. Considering you do not merge your pull request into master you can run: `git checkout master` and then `git pull upstream master`. Optionally `git push origin master`. 37 | 5. Create a new branch. Note that the starting point is the upstream master branch HEAD. `git checkout -b my-feature-name` 38 | 6. Stage all your changes `git add .` and commit them `git commit -m "Your commit message"` 39 | 7. Make sure your branch is up to date with upstream master, `git pull --rebase upstream master`, resolve conflicts if necessary. This will move your commit to the top of git stack. 40 | 8. Squash your commits into one commit. `git rebase -i HEAD~6` considering you did 6 commits. 41 | 9. Push your branch into your forked remote repository. 42 | 10. Create a new pull request adding any useful comment. 43 | 44 | 45 | ### Feature proposal 46 | 47 | We would love to hear your ideas and make a discussions about it. 48 | 49 | * Use [github issues](https://github.com/codefiesta/OAuthKit/issues) to make feature proposals. 50 | * We use `type: feature request` label to mark all [feature request issues](https://github.com/codefiesta/OAuthKit/labels/type%3A%20feature%20request). 51 | * Before submitting your proposal make sure there is no similar feature request. If you found a match feel free to join the discussion or just add a brief `+1` if you think the feature is worth implementing. 52 | * Be as specific as possible providing a precise explanation of feature request so anyone can understand the problem and the benefits of solving it. 53 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/Configuration.md: -------------------------------------------------------------------------------- 1 | # OAuthKit Configuration 2 | 3 | Explore advanced OAuth configuration options such as Keychain protection and private browsing. 4 | 5 | @Metadata { 6 | @PageKind(article) 7 | @PageImage( 8 | purpose: card, 9 | source: "config-card", 10 | alt: "OAuthKit Framework Configuration") 11 | @PageColor(purple) 12 | @Available(iOS, introduced: "18.0") 13 | @Available(macOS, introduced: "15.0") 14 | @Available(tvOS, introduced: "18.0") 15 | @Available(visionOS, introduced: "2.0") 16 | @Available(watchOS, introduced: "11.0") 17 | } 18 | 19 | ## Overview 20 | 21 | Both of the ``OAuth`` initializers ``OAuth/init(providers:options:)`` and ``OAuth/init(_:options:)`` accept a dictionary of `[OAuth.Option:Any]` which can be used to configure the loading and runtime options. 22 | 23 | ### Application Tag 24 | The ``OAuth/Option/applicationTag`` option is used to create unique keys for access tokens that are stored inside the Keychain. Typically, an app would set this value to be the same as their Bundle identifier. 25 | 26 | ```swift 27 | let options: [OAuth.Option: Any] = [ 28 | .applicationTag: "com.oauthkit.Sampler", 29 | ] 30 | let oauth: OAuth = .init(.main, options: options) 31 | ``` 32 | 33 | ### Auto Refresh 34 | The ``OAuth/Option/autoRefresh`` option is used to determine if tokens should be auto refreshed when they expire or not. This value is true by default. 35 | 36 | ```swift 37 | let options: [OAuth.Option: Any] = [ 38 | .autoRefresh: false, 39 | ] 40 | let oauth: OAuth = .init(.main, options: options) 41 | ``` 42 | 43 | ### URL Session 44 | The ``OAuth/Option/urlSession`` option is used to pass in a custom URLSession that will be used by your ``OAuth`` object to support any custom protocols or URL schemes that your app supports.This allows developers to register any custom URLProtocol classes that can handle the loading of protocol-specific URL data. 45 | 46 | ```swift 47 | // Custom URLSession 48 | let configuration: URLSessionConfiguration = .ephemeral 49 | configuration.protocolClasses = [CustomURLProtocol.self] 50 | let urlSession: URLSession = .init(configuration: configuration) 51 | 52 | let options: [OAuth.Option: Any] = [.urlSession: urlSession] 53 | ``` 54 | 55 | ### Private Browsing 56 | The ``OAuth/Option/useNonPersistentWebDataStore`` option allows developers to implement private browsing inside the ``OAWebView``. Setting this value to true forces the ``OAWebView`` to use a non-persistent data store, preventing data from being written to the file system. 57 | 58 | ```swift 59 | let options: [OAuth.Option: Any] = [ 60 | .useNonPersistentWebDataStore: true, 61 | ] 62 | let oauth: OAuth = .init(.module, options: options) 63 | ``` 64 | 65 | ### Keychain Protection 66 | The ``OAuth/Option/requireAuthenticationWithBiometricsOrCompanion`` option allows you to protect access to your keychain items with biometrics until successful local authentication. If the ``OAuth/Option/requireAuthenticationWithBiometricsOrCompanion`` option is set to true, the device owner will need to be authenticated by biometry or a companion device before keychain items (tokens) can be accessed. OAuthKit uses a default LAContext, but if you need fine-grained control while evaluating a user’s identity, pass your own custom LAContext to the options via the ``OAuth/Option/localAuthentication`` option. 67 | 68 | ```swift 69 | // Custom LAContext 70 | let localAuthentication: LAContext = .init() 71 | localAuthentication.localizedReason = "read tokens from keychain" 72 | localAuthentication.localizedFallbackTitle = "Use password" 73 | localAuthentication.touchIDAuthenticationAllowableReuseDuration = 10 74 | 75 | let options: [OAuth.Option: Any] = [ 76 | .localAuthentication: localAuthentication, 77 | .requireAuthenticationWithBiometricsOrCompanion: true 78 | ] 79 | let oauth: OAuth = .init(.module, options: options) 80 | ``` 81 | 82 | > Important: The ``OAuth/Option/requireAuthenticationWithBiometricsOrCompanion`` is only available on iOS, macOS, and visionOS. 83 | 84 | > Tip: See ``OAuth/Option`` for a complete list of configuration options. 85 | 86 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Views/OAWebViewCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAWebViewCoordinator.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | #if canImport(WebKit) 9 | import SwiftUI 10 | import WebKit 11 | 12 | /// Coordinates ``OAuth/state-swift.property`` changes inside a ``OAWebView``. 13 | @MainActor 14 | public class OAWebViewCoordinator: NSObject { 15 | 16 | var webView: OAWebView 17 | 18 | /// The oauth reference. 19 | var oauth: OAuth { 20 | webView.oauth 21 | } 22 | 23 | /// Initializer 24 | /// - Parameter webView: the webview that is being coordinated. 25 | init(_ webView: OAWebView) { 26 | self.webView = webView 27 | super.init() 28 | } 29 | 30 | /// Handles the authorization url for the specified provider. 31 | /// - Parameters: 32 | /// - url: the url to handle 33 | /// - provider: the oauth provider 34 | /// - grantType: the grant type to handle 35 | private func handle(url: URL, provider: OAuth.Provider, grantType: OAuth.GrantType) { 36 | 37 | guard let redirectURI = provider.redirectURI, url.absoluteString.starts(with: redirectURI) else { return } 38 | let urlComponents = URLComponents(string: url.absoluteString) 39 | let queryItems = urlComponents?.queryItems ?? [] 40 | guard queryItems.isNotEmpty else { return } 41 | guard let code = queryItems.filter({ $0.name == "code"}).first?.value else { return } 42 | guard let state = queryItems.filter({ $0.name == "state"}).first?.value else { return } 43 | 44 | switch grantType { 45 | case .authorizationCode(let authCodeState): 46 | 47 | // Verify the state 48 | guard state == authCodeState else { 49 | debugPrint("⚠️ State mismatch, expected [\(authCodeState)], received [\(state)]") 50 | return 51 | } 52 | 53 | if provider.debug { 54 | debugPrint("➡️ [AuthorizationCode], [\(url.absoluteString)], [\(code)]") 55 | } 56 | // Exchange the code for a token 57 | oauth.token(provider: provider, code: code) 58 | case .pkce(let pkce): 59 | // Verify the state 60 | guard state == pkce.state else { 61 | debugPrint("⚠️ State mismatch, expected [\(pkce.state)], received [\(state)]") 62 | return 63 | } 64 | if provider.debug { 65 | debugPrint("➡️ [PKCE], [\(url.absoluteString)], [\(code)]") 66 | } 67 | // Exchange the code for a token along with the pkce validation data 68 | oauth.token(provider: provider, code: code, pkce: pkce) 69 | case .clientCredentials, .deviceCode, .refreshToken: 70 | break 71 | } 72 | } 73 | 74 | /// Handles oauth state changes. 75 | /// - Parameter state: the published state change. 76 | func update(state: OAuth.State) { 77 | switch state { 78 | case .empty, .error, .authorized, .requestingAccessToken, .requestingDeviceCode: 79 | break 80 | case .authorizing(let provider, let grantType): 81 | // Override the custom user agent for the provider and tell the browser to load the request 82 | webView.view.customUserAgent = provider.customUserAgent 83 | // Tell the webView to load the authorization request 84 | guard let request = OAuth.Request.auth(provider: provider, grantType: grantType) else { return } 85 | webView.view.load(request) 86 | case .receivedDeviceCode(let provider, let deviceCode): 87 | // Override the custom user agent for the provider and tell the browser to load the request 88 | webView.view.customUserAgent = provider.customUserAgent 89 | // Tell the webView to load the device code verification request 90 | guard let url = URL(string: deviceCode.verificationUri) else { return } 91 | let request = URLRequest(url: url) 92 | webView.view.load(request) 93 | } 94 | } 95 | } 96 | 97 | extension OAWebViewCoordinator: WKNavigationDelegate { 98 | 99 | public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { 100 | guard let url = navigationAction.request.url else { return .cancel } 101 | switch oauth.state { 102 | case .empty, .error, .requestingAccessToken, .authorized, .requestingDeviceCode, .receivedDeviceCode: 103 | break 104 | case .authorizing(let provider, let grantType): 105 | handle(url: url, provider: provider, grantType: grantType) 106 | } 107 | return .allow 108 | } 109 | } 110 | 111 | #endif 112 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/Resources/oauth.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "Auth0", 4 | "authorizationURL": "https://domain/authorize", 5 | "accessTokenURL": "https://domain/oauth/token", 6 | "deviceCodeURL": "https://domain/oauth/device/code", 7 | "clientID": "CLIENT_ID", 8 | "clientSecret": "CLIENT_SECRET", 9 | "redirectURI": "https://github.com/codefiesta/", 10 | "scope": [ 11 | "email", 12 | "profile", 13 | "openid" 14 | ], 15 | "debug": true, 16 | }, 17 | { 18 | "id": "Box", 19 | "authorizationURL": "https://account.box.com/api/oauth2/authorize", 20 | "accessTokenURL": "https://api.box.com/oauth2/token", 21 | "clientID": "CLIENT_ID", 22 | "clientSecret": "CLIENT_SECRET", 23 | "redirectURI": "https://github.com/codefiesta/", 24 | "scope": [ 25 | "root_readwrite" 26 | ], 27 | "debug": true, 28 | }, 29 | { 30 | "id": "Dropbox", 31 | "authorizationURL": "https://www.dropbox.com/oauth2/authorize", 32 | "accessTokenURL": "https://api.dropboxapi.com/oauth2/token", 33 | "clientID": "CLIENT_ID", 34 | "clientSecret": "CLIENT_SECRET", 35 | "redirectURI": "https://github.com/codefiesta/", 36 | "scope": [ 37 | "email", 38 | "profile", 39 | "openid", 40 | "files.metadata.write", 41 | "files.content.write", 42 | ], 43 | "debug": true, 44 | }, 45 | { 46 | "id": "GitHub", 47 | "authorizationURL": "https://github.com/login/oauth/authorize", 48 | "accessTokenURL": "https://github.com/login/oauth/access_token", 49 | "deviceCodeURL": "https://github.com/login/device/code", 50 | "clientID": "CLIENT_ID", 51 | "clientSecret": "CLIENT_SECRET", 52 | "redirectURI": "oauthkit://callback", 53 | "scope": [ 54 | "user", 55 | "repo" 56 | ], 57 | "debug": true 58 | }, 59 | { 60 | "id": "Google", 61 | "authorizationURL": "https://accounts.google.com/o/oauth2/v2/auth", 62 | "accessTokenURL": "https://oauth2.googleapis.com/token", 63 | "deviceCodeURL": "https://oauth2.googleapis.com/device/code", 64 | "clientID": "CLIENT_ID", 65 | "clientSecret": "CLIENT_SECRET", 66 | "redirectURI": "https://github.com/codefiesta/", 67 | "scope": [ 68 | "email", 69 | "profile", 70 | "openid" 71 | ] 72 | }, 73 | { 74 | "id": "Microsoft", 75 | "authorizationURL": "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize", 76 | "accessTokenURL": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", 77 | "clientID": "CLIENT_ID", 78 | "clientSecret": "CLIENT_SECRET", 79 | "redirectURI": "https://github.com/codefiesta/", 80 | "scope": [ 81 | "email", 82 | "profile", 83 | "openid" 84 | ] 85 | }, 86 | { 87 | "id": "Slack", 88 | "authorizationURL": "https://slack.com/oauth/v2/authorize", 89 | "accessTokenURL": "https://slack.com/api/oauth.v2.access", 90 | "clientID": "CLIENT_ID", 91 | "clientSecret": "CLIENT_SECRET", 92 | "redirectURI": "https://github.com/codefiesta/", 93 | "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", 94 | "scope": [ 95 | "incoming-webhook" 96 | ] 97 | }, 98 | { 99 | "id": "Stripe", 100 | "authorizationURL": "https://marketplace.stripe.com/oauth/v2/authorize", 101 | "accessTokenURL": "https://api.stripe.com/v1/oauth/token", 102 | "clientID": "CLIENT_ID", 103 | "clientSecret": "CLIENT_SECRET", 104 | "redirectURI": "https://dashboard.stripe.com/test/apps-oauth/com.example.app.id", 105 | "scope": [ 106 | "connected_account_read", 107 | "balance_read", 108 | "charge_read", 109 | "customer_read", 110 | "order_read" 111 | ], 112 | "debug": true 113 | }, 114 | { 115 | "id": "LinkedIn", 116 | "authorizationURL": "https://www.linkedin.com/oauth/v2/authorization", 117 | "accessTokenURL": "https://www.linkedin.com/oauth/v2/accessToken", 118 | "clientID": "CLIENT_ID", 119 | "clientSecret": "CLIENT_SECRET", 120 | "redirectURI": "https://www.linkedin.com/developers/tools/oauth/redirect", 121 | "encodeHttpBody": false, 122 | "scope": [ 123 | "email", 124 | "profile", 125 | "openid" 126 | ] 127 | } 128 | ] 129 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+DeviceCode.swift: -------------------------------------------------------------------------------- 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 returned from a server that holds device code information. 13 | /// - SeeAlso: 14 | /// [Requesting a Device Code](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 | } 112 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAuthTestURLProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAauthTestURLProtocol.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | @testable import OAuthKit 10 | 11 | /// The version of the http response 12 | private let httpVersion = "HTTP/1.1" 13 | /// A successful status code 14 | private let statusCodeSuccess = 200 15 | /// A status code that indicates the error seems to have been caused by the client 16 | private let statusCodeBadRequest = 400 17 | /// A status code that indicates the server has encountered an error or is otherwise incapable of performing the request 18 | private let statusCodeServerError = 500 19 | 20 | /// Builds and responds to mock test requests. 21 | actor OAuthTestRequestHandler { 22 | 23 | let encoder: JSONEncoder = .init() 24 | let statusCode: Int 25 | 26 | /// Initializr 27 | /// - Parameter statusCode: the status code to return 28 | init(statusCode: Int = statusCodeSuccess) { 29 | self.statusCode = statusCode 30 | } 31 | 32 | /// Returns a mocked URL response for the given request and status code 33 | /// - Parameters: 34 | /// - request: the request 35 | /// - statusCode: the status code to return 36 | /// - Returns: an url response 37 | private func response(request: URLRequest) -> HTTPURLResponse { 38 | HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: httpVersion, headerFields: nil)! 39 | } 40 | 41 | /// Returns mock test data for the given request. 42 | /// - Parameter request: the request 43 | /// - Returns: the data to respond with 44 | private func data(request: URLRequest) -> Data { 45 | 46 | guard statusCode == statusCodeSuccess, let url = request.url else { return .init() } 47 | 48 | // Returns device code data 49 | if url.absoluteString.contains("grant_type=device_code") { 50 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: "0A17-B332", verificationUri: "https://github.com/codefiesta/OAuthKit", expiresIn: 2, interval: 1) 51 | return try! encoder.encode(deviceCode) 52 | } 53 | 54 | // Returns oauth access token data 55 | if url.absoluteString.contains("token") { 56 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: nil, type: "Bearer") 57 | return try! encoder.encode(token) 58 | } 59 | 60 | return .init() 61 | } 62 | 63 | /// Returns a mocked test response for the given url request 64 | /// - Parameter request: the url request to return a mock test response for 65 | /// - Returns: a tuple of response and data 66 | func execute(_ request: URLRequest) async throws -> (HTTPURLResponse, Data) { 67 | let response = response(request: request) 68 | let data = data(request: request) 69 | return (response, data) 70 | } 71 | } 72 | 73 | /// OAuth Test URL Protocol that intercepts test request and returns mocked response data. 74 | class OAuthTestURLProtocol: URLProtocol, @unchecked Sendable { 75 | 76 | /// The handler responsible for returning mocked test response data 77 | var handler: OAuthTestRequestHandler { 78 | .init() 79 | } 80 | 81 | /// Determines whether this protocol can handle the given request. 82 | /// - Parameter request: the request to handle 83 | /// - Returns: always true 84 | override class func canInit(with request: URLRequest) -> Bool { true } 85 | 86 | /// Returns the canonical version of the given request. 87 | /// - Parameter request: the request 88 | /// - Returns: the canonical version of the given request. 89 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } 90 | 91 | /// Starts loading the given request. 92 | override func startLoading() { 93 | Task { 94 | // Client error 95 | guard handler.statusCode != 500 else { 96 | client?.urlProtocol(self, didFailWithError: OAError.badResponse) 97 | return 98 | } 99 | do { 100 | let (response, data) = try await handler.execute(request) 101 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 102 | client?.urlProtocol(self, didLoad: data) 103 | client?.urlProtocolDidFinishLoading(self) 104 | } catch { 105 | client?.urlProtocol(self, didFailWithError: error) 106 | } 107 | } 108 | } 109 | 110 | /// Stops the loading of a request. 111 | override func stopLoading() {} 112 | } 113 | 114 | /// An URLProtocol that returns 400 error response and empty data. 115 | class OAuthTestClientErrorURLProtocol: OAuthTestURLProtocol, @unchecked Sendable { 116 | 117 | override var handler: OAuthTestRequestHandler { 118 | .init(statusCode: statusCodeBadRequest) 119 | } 120 | } 121 | 122 | /// An URLProtocol that returns 500 error responses and indicates that data has failed to load successfully. 123 | class OAuthTestServerErrorURLProtocol: OAuthTestURLProtocol, @unchecked Sendable { 124 | 125 | override var handler: OAuthTestRequestHandler { 126 | .init(statusCode: statusCodeServerError) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | @codefiesta. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+Provider.swift: -------------------------------------------------------------------------------- 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 2.0 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 | public var authorizationURL: URL 21 | /// The provider access token url. 22 | public var accessTokenURL: URL 23 | /// The provider device code url that can be used for devices without browsers (like tvOS). 24 | public var deviceCodeURL: URL? 25 | /// The provider redirect uri. 26 | public var redirectURI: String? 27 | /// The unique client identifier for interacting with this providers oauth server. 28 | var clientID: String 29 | /// The client's secret known only to the client and the providers oauth server. It is essential the client's password. 30 | var clientSecret: String? 31 | /// The provider scopes. 32 | public 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 | public 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 | public var customUserAgent: String? 41 | /// Enables provider debugging. Off by default. 42 | public var debug: Bool 43 | 44 | /// The coding keys. 45 | enum CodingKeys: String, CodingKey { 46 | case id 47 | case icon 48 | case authorizationURL 49 | case accessTokenURL 50 | case deviceCodeURL 51 | case clientID 52 | case clientSecret 53 | case redirectURI 54 | case scope 55 | case encodeHttpBody 56 | case customUserAgent 57 | case debug 58 | } 59 | 60 | /// Public initializer. 61 | /// - Parameters: 62 | /// - id: The provider unique id 63 | /// - icon: The provider icon 64 | /// - authorizationURL: The provider authorization url. 65 | /// - accessTokenURL: The provider access token url. 66 | /// - deviceCodeURL: The provider device code url. 67 | /// - clientID: The client id 68 | /// - clientSecret: The client secret 69 | /// - redirectURI: The redirect uri 70 | /// - scope: The oauth scope 71 | /// - encodeHttpBody: If the provider should encode the access token parameters into the http body (true by default) 72 | /// - customUserAgent: The custom user agent to send with browser requests. 73 | /// - debug: Boolean to pass debugging into to the standard output (false by default) 74 | public init(id: String, 75 | icon: URL? = nil, 76 | authorizationURL: URL, 77 | accessTokenURL: URL, 78 | deviceCodeURL: URL? = nil, 79 | clientID: String, 80 | clientSecret: String?, 81 | redirectURI: String? = nil, 82 | scope: [String]? = nil, 83 | encodeHttpBody: Bool = true, 84 | customUserAgent: String? = nil, 85 | debug: Bool = false) { 86 | self.id = id 87 | self.icon = icon 88 | self.authorizationURL = authorizationURL 89 | self.accessTokenURL = accessTokenURL 90 | self.deviceCodeURL = deviceCodeURL 91 | self.clientID = clientID 92 | self.clientSecret = clientSecret 93 | self.redirectURI = redirectURI 94 | self.scope = scope 95 | self.encodeHttpBody = encodeHttpBody 96 | self.customUserAgent = customUserAgent 97 | self.debug = debug 98 | } 99 | 100 | /// Custom decoder initializer. 101 | /// - Parameters: 102 | /// - decoder: the decoder to use 103 | public init(from decoder: any Decoder) throws { 104 | let container = try decoder.container(keyedBy: CodingKeys.self) 105 | id = try container.decode(String.self, forKey: .id) 106 | icon = try container.decodeIfPresent(URL.self, forKey: .icon) 107 | authorizationURL = try container.decode(URL.self, forKey: .authorizationURL) 108 | accessTokenURL = try container.decode(URL.self, forKey: .accessTokenURL) 109 | deviceCodeURL = try container.decodeIfPresent(URL.self, forKey: .deviceCodeURL) 110 | clientID = try container.decode(String.self, forKey: .clientID) 111 | clientSecret = try container.decodeIfPresent(String.self, forKey: .clientSecret) 112 | redirectURI = try container.decodeIfPresent(String.self, forKey: .redirectURI) 113 | scope = try container.decodeIfPresent([String].self, forKey: .scope) 114 | encodeHttpBody = try container.decodeIfPresent(Bool.self, forKey: .encodeHttpBody) ?? true 115 | customUserAgent = try container.decodeIfPresent(String.self, forKey: .customUserAgent) 116 | debug = try container.decodeIfPresent(Bool.self, forKey: .debug) ?? false 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/OAuthKit/Keychain/Keychain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keychain.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | import Security 10 | 11 | /// The default application tag to use. 12 | private let defaultApplicationTag = "oauthkit" 13 | /// The default token identifier suffix. 14 | private let tokenIdentifier = "oauth-token" 15 | 16 | /// A helper class used to interact with Keychain access. 17 | class Keychain: @unchecked Sendable { 18 | 19 | static let `default`: Keychain = Keychain() 20 | private let lock = NSLock() 21 | private let encoder = JSONEncoder() 22 | private let decoder = JSONDecoder() 23 | private var applicationTag: String = defaultApplicationTag 24 | 25 | private init() { } 26 | 27 | /// Initializes the keychain with an overridden application tag. 28 | /// - Parameter applicationTag: the application tag to use. Ideally, use the application identifier for this value. 29 | public init(_ applicationTag: String) { 30 | self.applicationTag = applicationTag 31 | } 32 | 33 | /// Queries the keychain for keys. 34 | var keys: [String] { 35 | 36 | let query: [String: Any] = [ 37 | kSecClass as String: kSecClassGenericPassword, 38 | kSecReturnAttributes as String: true, 39 | kSecMatchLimit as String: kSecMatchLimitAll 40 | ] 41 | 42 | var result: AnyObject? 43 | let status = withUnsafeMutablePointer(to: &result) { pointer in 44 | SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) 45 | } 46 | 47 | guard status == noErr else { return [] } 48 | 49 | var results = [String]() 50 | if let items = result as? [[String: Any]] { 51 | for item in items { 52 | if let key = item[kSecAttrAccount as String] as? String { 53 | results.append(key) 54 | } 55 | } 56 | } 57 | 58 | return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} 59 | } 60 | 61 | /// Sets the value for the specified key. 62 | /// - Parameters: 63 | /// - value: the value to store 64 | /// - key: the key to use 65 | /// - Returns: true if able to set the value, otherwise false 66 | @discardableResult 67 | func set(_ value: Codable, for key: String) throws -> Bool { 68 | assert(key.isNotEmpty, "❌ The keychain key cannot be empty.") 69 | lock.lock() 70 | defer { lock.unlock() } 71 | 72 | let account = accountKey(key) 73 | deleteNoLock(account) 74 | 75 | let data = try encoder.encode(value) 76 | let query: [String: Any] = [ 77 | kSecClass as String: kSecClassGenericPassword, 78 | kSecAttrAccount as String: account, 79 | kSecValueData as String: data 80 | ] 81 | 82 | let status = SecItemAdd(query as CFDictionary, nil) 83 | return status == errSecSuccess 84 | } 85 | 86 | /// Fetches a storeed value from the keychain with the specified key and attempts to decode it from the implied generic. 87 | /// - Parameter key: the keychain key 88 | /// - Returns: the generic codeable for the specified key or nil if not found 89 | func get(key: String) throws -> T? where T: Codable { 90 | 91 | lock.lock() 92 | defer { lock.unlock() } 93 | 94 | let account = accountKey(key) 95 | 96 | let query: [String: Any] = [ 97 | kSecClass as String: kSecClassGenericPassword, 98 | kSecMatchLimit as String: kSecMatchLimitOne, 99 | kSecAttrAccount as String: account, 100 | kSecReturnData as String: true 101 | ] 102 | 103 | var result: AnyObject? 104 | let status = withUnsafeMutablePointer(to: &result) { pointer in 105 | SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) 106 | } 107 | 108 | guard status == noErr, let data = result as? Data else { 109 | return nil 110 | } 111 | 112 | let value = try? decoder.decode(T.self, from: data) 113 | return value 114 | } 115 | 116 | /// Clears the keychain 117 | /// - Returns: true if values were cleared, otherwise false. 118 | @discardableResult 119 | func clear() -> Bool { 120 | 121 | lock.lock() 122 | defer { lock.unlock() } 123 | 124 | var results: [Bool] = [] 125 | for key in keys { 126 | results.append(deleteNoLock(key)) 127 | } 128 | 129 | guard results.isNotEmpty else { return true } 130 | return results.allSatisfy{ $0 == true } 131 | } 132 | 133 | /// Deletes the value for the specified key. 134 | /// - Parameter key: the key to delete 135 | /// - Returns: true if able to delete from the keychain, otherwise false 136 | @discardableResult 137 | func delete(key: String) -> Bool { 138 | lock.lock() 139 | defer { lock.unlock() } 140 | 141 | let account = accountKey(key) 142 | return deleteNoLock(account) 143 | } 144 | 145 | /// Attempts to delete the value for the specifed key without a lock in place. 146 | /// - Parameter key: the key to delete 147 | /// - Returns: true if able to delete from the keychain, otherwise false 148 | @discardableResult 149 | private func deleteNoLock(_ key: String) -> Bool { 150 | let query: [String: Any] = [ 151 | kSecClass as String: kSecClassGenericPassword, 152 | kSecAttrAccount as String: key 153 | ] 154 | let status = SecItemDelete(query as CFDictionary) 155 | return status == noErr 156 | } 157 | 158 | /// Builds the account key by prefixing the specified key with the application tag. 159 | /// - Parameter key: the key to prefix. 160 | /// - Returns: the unique account key to use 161 | private func accountKey(_ key: String) -> String { 162 | applicationTag + "." + key + "." + tokenIdentifier 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAWebViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAWebViewTests.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | #if canImport(WebKit) 9 | import Foundation 10 | @testable import OAuthKit 11 | import SwiftUI 12 | import Testing 13 | import WebKit 14 | 15 | @MainActor 16 | @Suite("OAuthWebView Tests", .tags(.views)) 17 | final class OAWebViewTests { 18 | 19 | /// The mock url session that overrides the protocol classes with `OAuthTestURLProtocol` 20 | /// that will intercept all outbound requests and return mocked test data. 21 | private static let urlSession: URLSession = { 22 | let configuration: URLSessionConfiguration = .ephemeral 23 | configuration.protocolClasses = [OAuthTestURLProtocol.self] 24 | return .init(configuration: configuration) 25 | }() 26 | 27 | let oauth: OAuth 28 | let tag: String 29 | let webView: OAWebView 30 | var provider: OAuth.Provider { 31 | oauth.providers.filter{ $0.id == "GitHub" }.first! 32 | } 33 | 34 | var keychain: Keychain { 35 | oauth.keychain 36 | } 37 | 38 | /// Initializer. 39 | init() async throws { 40 | tag = "oauthkit.test." + .secureRandom() 41 | let options: [OAuth.Option: Any] = [ 42 | .applicationTag: tag, 43 | .autoRefresh: false, 44 | .useNonPersistentWebDataStore: true, 45 | .urlSession: Self.urlSession 46 | ] 47 | oauth = .init(.module, options: options) 48 | webView = .init(oauth: oauth) 49 | #expect(oauth.useNonPersistentWebDataStore == true) 50 | } 51 | 52 | /// Tests the oauth environment values are correct. 53 | /// This is kind of a lame test but but provides code coverage for that extension. 54 | @Test("OAuth EnvironmentValues") 55 | func testEnvironmentValues() async throws { 56 | var values: EnvironmentValues = .init() 57 | values.oauth = oauth 58 | let environmentOAuth = values.oauth 59 | #expect(environmentOAuth.providers == oauth.providers) 60 | #expect(environmentOAuth.state == oauth.state) 61 | } 62 | 63 | /// Tests the OAWebViewCoordinator coordinator policy decisions. 64 | @Test("Coordinator Policy Decisons") 65 | func whenCoordinatorDecidingPolicy() async throws { 66 | 67 | // 1) Bad Request Expectations 68 | let coordinator: OAWebViewCoordinator = webView.makeCoordinator() 69 | #expect(coordinator.oauth.state == oauth.state) 70 | let wkWebView = webView.view 71 | 72 | var urlRequest: URLRequest = .init(url: URL(string: "https://github.com/codefiesta/OAuthKit")!) 73 | urlRequest.url = nil 74 | 75 | var navigationAction: WKNavigationAction = OAuthTestWKNavigationAction(urlRequest: urlRequest) 76 | var policy = await coordinator.webView(wkWebView, decidePolicyFor: navigationAction) 77 | #expect(policy == .cancel) 78 | 79 | // 2) Authorization Code Expectations 80 | let state: String = .secureRandom() 81 | let code: String = .secureRandom() 82 | 83 | oauth.authorize(provider: provider, grantType: .authorizationCode(state)) 84 | coordinator.update(state: oauth.state) 85 | var urlString = provider.redirectURI! + "?code=\(code)&state=\(state)" 86 | urlRequest = .init(url: URL(string: urlString)!) 87 | 88 | navigationAction = OAuthTestWKNavigationAction(urlRequest: urlRequest) 89 | policy = await coordinator.webView(wkWebView, decidePolicyFor: navigationAction) 90 | #expect(policy == .allow) 91 | 92 | // 3) PKCE Expectations 93 | let pkce: OAuth.PKCE = .init() 94 | oauth.authorize(provider: provider, grantType: .pkce(pkce)) 95 | coordinator.update(state: oauth.state) 96 | urlString = provider.redirectURI! + "?code=\(code)&state=\(pkce.state)" 97 | urlRequest = .init(url: URL(string: urlString)!) 98 | 99 | navigationAction = OAuthTestWKNavigationAction(urlRequest: urlRequest) 100 | policy = await coordinator.webView(wkWebView, decidePolicyFor: navigationAction) 101 | #expect(policy == .allow) 102 | let result = await waitForAuthorization() 103 | #expect(result == true) 104 | } 105 | 106 | /// Tests to make sure the coordinator doesn't being requesting access tokens when we've detected state mismatches. 107 | @Test("Coordinator Detects Mismatched States") 108 | func whenCoordinatorDetectsMismatchedStates() async throws { 109 | 110 | // 1) Bad Request Expectations 111 | let coordinator: OAWebViewCoordinator = webView.makeCoordinator() 112 | #expect(coordinator.oauth.state == oauth.state) 113 | let wkWebView = webView.view 114 | 115 | var urlRequest: URLRequest = .init(url: URL(string: "https://github.com/codefiesta/OAuthKit")!) 116 | 117 | // 2) Authorization Code Expectations 118 | let state: String = .secureRandom() 119 | let code: String = .secureRandom() 120 | 121 | oauth.authorize(provider: provider, grantType: .authorizationCode(state)) 122 | let urlString = provider.redirectURI! + "?code=\(code)&state=ABC-123" 123 | urlRequest = .init(url: URL(string: urlString)!) 124 | var navigationAction: WKNavigationAction = OAuthTestWKNavigationAction(urlRequest: urlRequest) 125 | var policy = await coordinator.webView(wkWebView, decidePolicyFor: navigationAction) 126 | #expect(policy == .allow) 127 | #expect(oauth.state != .requestingAccessToken(provider)) 128 | 129 | // 3) PKCE Expectations 130 | let pkce: OAuth.PKCE = .init() 131 | oauth.authorize(provider: provider, grantType: .pkce(pkce)) 132 | coordinator.update(state: oauth.state) 133 | 134 | navigationAction = OAuthTestWKNavigationAction(urlRequest: urlRequest) 135 | policy = await coordinator.webView(wkWebView, decidePolicyFor: navigationAction) 136 | #expect(policy == .allow) 137 | #expect(oauth.state != .requestingAccessToken(provider)) 138 | } 139 | 140 | /// Streams the oauth status until we receive an authorization. 141 | /// This should only be used on test methods that expect an authorization to be inserted into the keychain. 142 | private func waitForAuthorization() async -> Bool { 143 | let monitor: OAuth.Monitor = .init(oauth: oauth) 144 | for await state in monitor.stream { 145 | switch state { 146 | case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: 147 | break 148 | case .authorized(_, _): 149 | keychain.clear() 150 | return true 151 | } 152 | } 153 | return false 154 | } 155 | } 156 | #endif 157 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/UtilityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UtilityTests.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | @testable import OAuthKit 10 | import Testing 11 | 12 | @Suite("Utility Tests", .tags(.utility)) 13 | struct UtilityTests { 14 | 15 | /// Tests the Base64 encoding. 16 | @Test("Base64 Encoding and Decoding") 17 | func whenBase64Encoding() async throws { 18 | let string = "https://github.com/codefiesta/OAuthKit" 19 | let encoded = string.base64 20 | let decoded = encoded.base64Decoded 21 | #expect(string == decoded) 22 | } 23 | 24 | /// Tests the Base64 URL encoding. 25 | @Test("Base64 URL Encoding and Decoding") 26 | func whenBase64URLEncoding() async throws { 27 | let string = "https://github.com/codefiesta/OAuthKit" 28 | let encoded = string.base64URL 29 | let decoded = encoded.base64URLDecoded 30 | 31 | let url = URL(string: string)! 32 | let urlEncoded = url.absoluteString.base64URL 33 | #expect(encoded == urlEncoded) 34 | 35 | #expect(string == decoded) 36 | } 37 | 38 | /// Tests the SHA-256 hex string output. 39 | @Test("SHA-256 Hex") 40 | func whenSHA256Hex() async throws { 41 | let rawString = "https://github.com/codefiesta/OAuthKit" 42 | let result = rawString.sha256.hex 43 | let expectedResult = "554b0a051b6488645455eac6ddaf0978be24494bf037b1692daa9e330257ea3a" 44 | #expect(result == expectedResult) 45 | 46 | let uuid = UUID() 47 | let uuidHex = uuid.sha256.hex 48 | let expectedUUIDHex = uuid.uuidString.sha256.hex 49 | #expect(uuidHex == expectedUUIDHex) 50 | } 51 | 52 | /// Tests the SHA-256 Base64 string output. 53 | @Test("SHA-256 Base64") 54 | func whenSHA256Base64() async throws { 55 | let rawString = "https://github.com/codefiesta/OAuthKit" 56 | let result = rawString.sha256.base64 57 | let expectedResult = "VUsKBRtkiGRUVerG3a8JeL4kSUvwN7FpLaqeMwJX6jo=" 58 | #expect(result == expectedResult) 59 | 60 | let url = URL(string: rawString)! 61 | let urlResult = url.sha256.base64 62 | #expect(result == urlResult) 63 | } 64 | 65 | /// Tests the SHA-256 Base64 URL string output. 66 | @Test("SHA-256 Base64 URL") 67 | func whenSHA256Base64URL() async throws { 68 | let rawString = "https://github.com/codefiesta/OAuthKit" 69 | let result = rawString.sha256.base64URL 70 | let expectedResult = "VUsKBRtkiGRUVerG3a8JeL4kSUvwN7FpLaqeMwJX6jo" 71 | #expect(result == expectedResult) 72 | } 73 | 74 | /// Tests the SHA-512 Base64 string output. 75 | @Test("SHA-512 Base64") 76 | func whenSHA512Base64() async throws { 77 | let rawString = "https://github.com/codefiesta/OAuthKit" 78 | let result = rawString.sha512.base64 79 | let expectedResult = "rz5qYziciQqhYSmnADG0Qzs8MfbM4qt8f5OFQ80flD87/9yYaHMEorrCYO/M6H6rM/qoWmqQ1NKN3vwIagBrmQ==" 80 | #expect(result == expectedResult) 81 | 82 | let url = URL(string: rawString)! 83 | let urlResult = url.sha512.base64 84 | #expect(result == urlResult) 85 | } 86 | 87 | /// Tests the SHA-512 Base64 URL string output. 88 | @Test("SHA-512 Base64URL") 89 | func whenSHA512Base64URL() async throws { 90 | let rawString = "https://github.com/codefiesta/OAuthKit" 91 | let result = rawString.sha512.base64URL 92 | let expectedResult = "rz5qYziciQqhYSmnADG0Qzs8MfbM4qt8f5OFQ80flD87_9yYaHMEorrCYO_M6H6rM_qoWmqQ1NKN3vwIagBrmQ" 93 | #expect(result == expectedResult) 94 | 95 | let url = URL(string: rawString)! 96 | let urlResult = url.sha512.base64URL 97 | #expect(result == urlResult) 98 | } 99 | 100 | /// Tests the secure random byte generation. 101 | @Test("Secure Random Byte Generation") 102 | func whenGeneratingSecureRandomData() async throws { 103 | var random: Data = .secureRandom() 104 | #expect(random.count == 32) 105 | random = Data.secureRandom(count: 64) 106 | #expect(random.count == 64) 107 | } 108 | 109 | /// Tests URL as Base 64 encoding. 110 | @Test("URL Base64 Encoding") 111 | func whenEncodingDataBase64() async throws { 112 | let rawString = "https://github.com/codefiesta/OAuthKit" 113 | let encoded: String = rawString.base64 114 | let decoded = encoded.base64Decoded 115 | 116 | let url = URL(string: rawString)! 117 | #expect(encoded == url.base64) 118 | #expect(rawString == decoded) 119 | } 120 | 121 | /// Tests the encoding of data as Base 64 URL encoded. 122 | @Test("Data Base64 URL Encoding") 123 | func whenEncodingDataBase64URL() async throws { 124 | let rawString = "https://github.com/codefiesta/OAuthKit" 125 | let data: Data = rawString.data(using: .utf8)! 126 | let encoded: String = data.base64URLEncoded 127 | let expected = "aHR0cHM6Ly9naXRodWIuY29tL2NvZGVmaWVzdGEvT0F1dGhLaXQ" 128 | #expect(encoded == expected) 129 | 130 | let url = URL(string: rawString)! 131 | #expect(encoded == url.base64URL) 132 | } 133 | 134 | /// Tests the generation of random string generation. 135 | @Test("Secure Random String Generation") 136 | func whenGeneratingSecureRandomString() async throws { 137 | let random: String = .secureRandom() 138 | #expect(random.count >= 43) 139 | } 140 | 141 | /// Tests the scheduling of tasks 142 | @Test("Scheduling Tasks") 143 | func whenSchedulingTasks() async throws { 144 | let timeInterval: TimeInterval = 0 145 | let task: Task = .delayed(timeInterval: timeInterval) { 146 | return true 147 | } 148 | let executed = try await task.value 149 | #expect(executed) 150 | } 151 | 152 | /// Tests the network monitor 153 | @MainActor 154 | @Test("Network Monitor") 155 | func whenNetworkMonitoring() async throws { 156 | let monitor: NetworkMonitor = .shared 157 | #expect(monitor.isOnline == false) 158 | withObservationTracking { 159 | _ = monitor.isOnline 160 | } onChange: { 161 | Task { @MainActor in 162 | #expect(monitor.isOnline) 163 | } 164 | } 165 | Task { @MainActor in 166 | await monitor.start() 167 | #expect(monitor.isMonitoring) 168 | } 169 | // The second call to monitor.start() should simply bail as monitoring 170 | // is already happening and not toggle the internal `isMonitoring` flag. 171 | Task { @MainActor in 172 | await monitor.start() 173 | #expect(monitor.isMonitoring) 174 | } 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuthKit.docc/GettingStarted.md: -------------------------------------------------------------------------------- 1 | # Getting Started with OAuthKit 2 | @Metadata { 3 | @PageKind(article) 4 | @PageImage( 5 | purpose: card, 6 | source: "gettingStarted-card", 7 | alt: "Getting Started with OAuthKit") 8 | @PageColor(purple) 9 | @Available(iOS, introduced: "18.0") 10 | @Available(macOS, introduced: "15.0") 11 | @Available(tvOS, introduced: "18.0") 12 | @Available(visionOS, introduced: "2.0") 13 | @Available(watchOS, introduced: "11.0") 14 | } 15 | 16 | Learn how to create an observable OAuth instance and start an authorization flow. 17 | 18 | 19 | ## Overview 20 | 21 | OAuth 2.0 authorization flows are started by calling ``OAuth/authorize(provider:grantType:)`` with an ``OAuth/Provider`` and ``OAuth/GrantType``. 22 | 23 | ```swift 24 | // Create an observable OAuth object 25 | let oauth: OAuth = .init(.main) 26 | 27 | // Start an authorization flow 28 | let grantType: OAuth.GrantType = .pkce(.init()) 29 | oauth.authorize(provider: provider, grantType: grantType) 30 | ``` 31 | 32 | ## Observing OAuth State 33 | 34 | 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: 35 | 36 | ```swift 37 | @main 38 | struct OAuthApp: App { 39 | 40 | @Environment(\.oauth) 41 | var oauth: OAuth 42 | 43 | /// Build the scene body 44 | var body: some Scene { 45 | 46 | WindowGroup { 47 | ContentView() 48 | } 49 | 50 | #if canImport(WebKit) 51 | WindowGroup(id: "oauth") { 52 | OAWebView(oauth: oauth) 53 | } 54 | #endif 55 | } 56 | } 57 | 58 | struct ContentView: View { 59 | 60 | @Environment(\.oauth) 61 | var oauth: OAuth 62 | 63 | @Environment(\.openWindow) 64 | var openWindow 65 | 66 | @Environment(\.dismissWindow) 67 | private var dismissWindow 68 | 69 | var body: some View { 70 | VStack { 71 | switch oauth.state { 72 | case .empty: 73 | providerList 74 | case .authorizing(let provider, let grantType): 75 | Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") 76 | case .requestingAccessToken(let provider): 77 | Text("Requesting Access Token [\(provider.id)]") 78 | case .requestingDeviceCode(let provider): 79 | Text("Requesting Device Code [\(provider.id)]") 80 | case .authorized(let provider, _): 81 | Button("Authorized [\(provider.id)]") { 82 | oauth.clear() 83 | } 84 | case .receivedDeviceCode(_, let deviceCode): 85 | Text("To login, visit") 86 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 87 | .foregroundStyle(.blue) 88 | Text("and enter the following code:") 89 | Text(deviceCode.userCode) 90 | .padding() 91 | .border(Color.primary) 92 | .font(.title) 93 | case .error(let provider, let error): 94 | Text("Error [\(provider.id)]: \(error.localizedDescription)") 95 | } 96 | } 97 | .onChange(of: oauth.state) { _, state in 98 | handle(state: state) 99 | } 100 | } 101 | 102 | /// Displays a list of oauth providers. 103 | var providerList: some View { 104 | List(oauth.providers) { provider in 105 | Button(provider.id) { 106 | authorize(provider: provider) 107 | } 108 | } 109 | } 110 | 111 | /// Starts the authorization process for the specified provider. 112 | /// - Parameter provider: the provider to begin authorization for 113 | private func authorize(provider: OAuth.Provider) { 114 | let grantType: OAuth.GrantType = .pkce(.init()) 115 | oauth.authorize(provider: provider, grantType: grantType) 116 | } 117 | 118 | /// Reacts to oauth state changes by opening or closing authorization windows. 119 | /// - Parameter state: the published state change 120 | private func handle(state: OAuth.State) { 121 | #if canImport(WebKit) 122 | switch state { 123 | case .empty, .error, .requestingAccessToken, .requestingDeviceCode: 124 | break 125 | case .authorizing, .receivedDeviceCode: 126 | openWindow(id: "oauth") 127 | case .authorized(_, _): 128 | dismissWindow(id: "oauth") 129 | } 130 | #endif 131 | } 132 | } 133 | ``` 134 | 135 | ## Presenting Authorization Windows 136 | When a ``OAuth/state`` has reached an ``OAuth/State/authorizing(_:_:)`` state, your application should present 137 | a browser window that allows the user to authenticate with an ``OAuth/Provider``. 138 | OAuthKit provides an out of the box SwiftUI view ``OAWebView`` that will automatically handle the rest of the steps in the OAuth authorization flow for you. 139 | 140 | ```swift 141 | /// Handle `.authorizing` or `.authorized` oauth state changes by opening or closing authorization windows. 142 | /// - Parameter state: the published state change 143 | func handle(state: OAuth.State) { 144 | switch state { 145 | case .empty, .error, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: 146 | break 147 | case .authorizing: 148 | openWindow(id: "oauth") 149 | case .authorized: 150 | dismissWindow(id: "oauth") 151 | } 152 | } 153 | ``` 154 | 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. 155 | 156 | > 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:)`` . 157 | 158 | ## Presenting Device Codes (tvOS and watchOS) 159 | 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. 160 | 161 | ```swift 162 | let grantType: OAuth.GrantType = .deviceCode 163 | oauth.authorize(provider: provider, grantType: grantType) 164 | 165 | struct ContentView: View { 166 | ... 167 | var body: some View { 168 | VStack { 169 | switch oauth.state { 170 | case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode: 171 | EmptyView() 172 | case .authorized: 173 | Text("Authorized [\(provider.id)]") 174 | case .receivedDeviceCode(_, let deviceCode): 175 | Text("To login, visit") 176 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 177 | .foregroundStyle(.blue) 178 | Text("and enter the following code:") 179 | Text(deviceCode.userCode) 180 | .padding() 181 | .border(Color.primary) 182 | .font(.title) 183 | } 184 | } 185 | } 186 | } 187 | ``` 188 | 189 | The observable ``OAuth`` instance will proceed to poll the ``OAuth/Provider`` access token endpoint until the device code has expired or has successfully received an ``OAuth/Token`` and moved to an ``OAuth/State/authorized(_:_:)`` state. 190 | 191 | > 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. 192 | 193 | ### Related Tutorials 194 | 195 | @Links(visualStyle: list) { 196 | - 197 | } 198 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth+Request.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth+Request.swift 3 | // OAuthKit 4 | // 5 | // Created by Kevin McKee 6 | // 7 | 8 | import Foundation 9 | 10 | private let httpPost = "POST" 11 | private let httpAcceptHeaderField = "Accept" 12 | private let jsonMimeType = "application/json" 13 | private let responseTypeCode = "code" 14 | 15 | extension OAuth { 16 | 17 | /// OAuth URL Request Builder 18 | struct Request { 19 | 20 | /// Provides constants for URLQueryItem keys. 21 | fileprivate enum Key: String { 22 | case code = "code" 23 | case clientID = "client_id" 24 | case clientSecret = "client_secret" 25 | case deviceCode = "device_code" 26 | case grantType = "grant_type" 27 | case redirectUri = "redirect_uri" 28 | case scope = "scope" 29 | case state = "state" 30 | case refreshToken = "refresh_token" 31 | case responseType = "response_type" 32 | case codeChallenge = "code_challenge" 33 | case codeChallengeMethod = "code_challenge_method" 34 | case codeVerifier = "code_verifier" 35 | } 36 | 37 | // MARK: URLRequest Builders 38 | 39 | /// Builds an `/authorization` request for the specified provider and grant type. 40 | /// - Parameters: 41 | /// - provider: the oauth provider 42 | /// - grantType: the grant type 43 | /// - Returns: an `/authorization` url request 44 | static func auth(provider: Provider, grantType: GrantType) -> URLRequest? { 45 | guard var urlComponents = URLComponents(string: provider.authorizationURL.absoluteString) else { return nil } 46 | guard let queryItems = buildQueryItems(provider: provider, grantType: grantType) else { return nil } 47 | urlComponents.queryItems = queryItems 48 | guard let url = urlComponents.url else { return nil } 49 | return URLRequest(url: url) 50 | } 51 | 52 | /// Builds an `/authorization` request for refreshing the given token. 53 | /// - Parameters: 54 | /// - provider: the oauth provider 55 | /// - token: the auth token to refresh 56 | /// - Returns: an `/authorization` url request 57 | static func refresh(provider: Provider, token: Token) -> URLRequest? { 58 | guard var urlComponents = URLComponents(string: provider.authorizationURL.absoluteString) else { return nil } 59 | guard let queryItems = buildQueryItems(provider: provider, token: token) else { return nil } 60 | urlComponents.queryItems = queryItems 61 | guard let url = urlComponents.url else { return nil } 62 | 63 | var request = URLRequest(url: url) 64 | request.httpMethod = httpPost 65 | request.setValue(jsonMimeType, forHTTPHeaderField: httpAcceptHeaderField) 66 | return request 67 | } 68 | 69 | /// Builds a `/token` request for exchanging the given code for a token. 70 | /// This method will either encode the query items into the http body (using application/x-www-form-urlencoded) 71 | /// or simply send the query item parameters with the request based on how the provider is implemented. 72 | /// If you are seeing errors when fetching access tokens from a provider, it may be necessary to 73 | /// set the `encodeHttpBody` parameter to false as server implementations vary across providers. 74 | /// - Parameters: 75 | /// - provider: the oauth provider 76 | /// - code: the code to exchange for a token 77 | /// - pkce: the pkce data 78 | /// - Returns: a `/token` url request 79 | static func token(provider: Provider, code: String, pkce: PKCE? = nil) -> URLRequest? { 80 | guard var urlComponents = URLComponents(string: provider.accessTokenURL.absoluteString) else { return nil } 81 | let queryItems = buildQueryItems(provider: provider, code: code, pkce: pkce) 82 | urlComponents.queryItems = queryItems 83 | 84 | guard var url = urlComponents.url else { return nil } 85 | 86 | // If we're encoding the http body, rebuild the url without the query items 87 | if provider.encodeHttpBody { 88 | urlComponents = URLComponents() 89 | urlComponents.queryItems = queryItems 90 | url = provider.accessTokenURL 91 | } 92 | 93 | var request = URLRequest(url: url) 94 | request.httpBody = provider.encodeHttpBody ? urlComponents.query?.data(using: .utf8) : nil 95 | request.httpMethod = httpPost 96 | request.setValue(jsonMimeType, forHTTPHeaderField: httpAcceptHeaderField) 97 | return request 98 | } 99 | 100 | /// Builds a `/token` request that can be used for polling the token endpoint until the user has approved the request. 101 | /// - Parameters: 102 | /// - provider: the oauth provider 103 | /// - deviceCode: the device code data 104 | /// - Returns: a `/token` request that can be used for polling 105 | static func token(provider: Provider, deviceCode: DeviceCode) -> URLRequest? { 106 | guard var urlComponents = URLComponents(string: provider.accessTokenURL.absoluteString) else { return nil } 107 | urlComponents.queryItems = buildQueryItems(provider: provider, deviceCode: deviceCode) 108 | guard let url = urlComponents.url else { return nil } 109 | var request = URLRequest(url: url) 110 | request.httpMethod = httpPost 111 | request.setValue(jsonMimeType, forHTTPHeaderField: httpAcceptHeaderField) 112 | return request 113 | } 114 | 115 | /// Builds a client credentials `/token` request. 116 | /// - Parameters: 117 | /// - provider: the oauth provider 118 | /// - Returns: the url request 119 | static func token(provider: Provider) -> URLRequest? { 120 | guard var urlComponents = URLComponents(string: provider.accessTokenURL.absoluteString) else { return nil } 121 | guard let queryItems = buildQueryItems(provider: provider, grantType: .clientCredentials) else { return nil } 122 | urlComponents.queryItems = queryItems 123 | 124 | guard var url = urlComponents.url else { return nil } 125 | 126 | // If we're encoding the http body, rebuild the url without the query items 127 | if provider.encodeHttpBody { 128 | urlComponents = URLComponents() 129 | urlComponents.queryItems = queryItems 130 | url = provider.accessTokenURL 131 | } 132 | 133 | var request = URLRequest(url: url) 134 | request.httpBody = provider.encodeHttpBody ? urlComponents.query?.data(using: .utf8) : nil 135 | request.httpMethod = httpPost 136 | request.setValue(jsonMimeType, forHTTPHeaderField: httpAcceptHeaderField) 137 | return request 138 | } 139 | 140 | /// Builds a `/device` code request. 141 | /// - Parameters: 142 | /// - provider: the oauth provider 143 | /// - Returns: the url request 144 | static func device(provider: Provider) -> URLRequest? { 145 | guard let deviceCodeURL = provider.deviceCodeURL, 146 | var urlComponents = URLComponents(string: deviceCodeURL.absoluteString) else { return nil } 147 | urlComponents.queryItems = buildQueryItems(provider: provider, grantType: .deviceCode) 148 | guard let url = urlComponents.url else { return nil } 149 | var request = URLRequest(url: url) 150 | request.httpMethod = httpPost 151 | request.setValue(jsonMimeType, forHTTPHeaderField: httpAcceptHeaderField) 152 | return request 153 | } 154 | 155 | // MARK: Query Items 156 | 157 | /// Builds default url query items for the specified provider and grant type. 158 | /// 159 | /// Important notes about the `grantType`: 160 | /// * When `.authorizationCode`,`.pkce`, or `.refreshToken` the items are built for `/authorization` requests. 161 | /// * When `.deviceCode` the items are built for `/device` requests. 162 | /// * When `.clientCredentials` the items are built for `/token` requests. 163 | /// * When `.refreshToken` the items are nil 164 | /// - Parameters: 165 | /// - provider: the oauth provider 166 | /// - grantType: the grant type 167 | /// - Returns: the default url query items 168 | private static func buildQueryItems(provider: Provider, grantType: GrantType) -> [URLQueryItem]? { 169 | var queryItems = [URLQueryItem]() 170 | switch grantType { 171 | case .authorizationCode(let state): 172 | queryItems.append(URLQueryItem(key: .clientID, value: provider.clientID)) 173 | queryItems.append(URLQueryItem(key: .redirectUri, value: provider.redirectURI)) 174 | queryItems.append(URLQueryItem(key: .responseType, value: responseTypeCode)) 175 | queryItems.append(URLQueryItem(key: .state, value: state)) 176 | case .pkce(let pkce): 177 | queryItems.append(URLQueryItem(key: .clientID, value: provider.clientID)) 178 | queryItems.append(URLQueryItem(key: .redirectUri, value: provider.redirectURI)) 179 | queryItems.append(URLQueryItem(key: .responseType, value: responseTypeCode)) 180 | queryItems.append(URLQueryItem(key: .state, value: pkce.state)) 181 | queryItems.append(URLQueryItem(key: .codeChallenge, value: pkce.codeChallenge)) 182 | queryItems.append(URLQueryItem(key: .codeChallengeMethod, value: pkce.codeChallengeMethod)) 183 | case .deviceCode: 184 | queryItems.append(URLQueryItem(key: .clientID, value: provider.clientID)) 185 | queryItems.append(URLQueryItem(key: .clientSecret, value: provider.clientSecret)) 186 | queryItems.append(URLQueryItem(key: .grantType, value: grantType.rawValue)) 187 | case .clientCredentials: 188 | queryItems.append(URLQueryItem(key: .clientID, value: provider.clientID)) 189 | queryItems.append(URLQueryItem(key: .clientSecret, value: provider.clientSecret)) 190 | queryItems.append(URLQueryItem(key: .grantType, value: grantType.rawValue)) 191 | case .refreshToken: 192 | return nil 193 | } 194 | if let scope = provider.scope { 195 | queryItems.append(URLQueryItem(key: .scope, value: scope.joined(separator: " "))) 196 | } 197 | return queryItems 198 | } 199 | 200 | /// Builds default url query items for the `.refreshToken` grant type. 201 | /// - Parameters: 202 | /// - provider: the oauth provider 203 | /// - token: the token to refresh 204 | /// - Returns: the `/token` url query items 205 | private static func buildQueryItems(provider: Provider, token: Token) -> [URLQueryItem]? { 206 | var queryItems = [URLQueryItem]() 207 | guard let refreshToken = token.refreshToken else { return nil } 208 | queryItems.append(URLQueryItem(key: .clientID, value: provider.clientID)) 209 | queryItems.append(URLQueryItem(key: .grantType, value: GrantType.refreshToken.rawValue)) 210 | queryItems.append(URLQueryItem(key: .refreshToken, value: refreshToken)) 211 | return queryItems 212 | } 213 | 214 | /// Builds `/token` url query parameters for the specified code and pkce data. 215 | /// - Parameters: 216 | /// - provider: the oauth provider 217 | /// - code: the code to exchange for a token 218 | /// - pkce: the pkce data 219 | /// - Returns: the `/token` url query items 220 | private static func buildQueryItems(provider: Provider, code: String, pkce: PKCE? = nil) -> [URLQueryItem] { 221 | let grantType: GrantType = .authorizationCode(.empty) 222 | var queryItems: [URLQueryItem] = [ 223 | URLQueryItem(key: .clientID, value: provider.clientID), 224 | URLQueryItem(key: .clientSecret, value: provider.clientSecret), 225 | URLQueryItem(key: .code, value: code), 226 | URLQueryItem(key: .redirectUri, value: provider.redirectURI), 227 | URLQueryItem(key: .grantType, value: grantType.rawValue) 228 | ] 229 | 230 | if let pkce { 231 | queryItems.append(URLQueryItem(key: .codeVerifier, value: pkce.codeVerifier)) 232 | } 233 | 234 | if let scope = provider.scope { 235 | queryItems.append(URLQueryItem(key: .scope, value: scope.joined(separator: " "))) 236 | } 237 | return queryItems 238 | } 239 | 240 | /// Builds `/token` url query parameters for the specified provider and device code data. 241 | /// - Parameters: 242 | /// - provider: the oauth provider 243 | /// - deviceCode: the device code data 244 | /// - Returns: the `/token` url query items 245 | private static func buildQueryItems(provider: Provider, deviceCode: DeviceCode) -> [URLQueryItem] { 246 | var queryItems: [URLQueryItem] = [ 247 | URLQueryItem(key: .clientID, value: provider.clientID), 248 | URLQueryItem(key: .grantType, value: DeviceCode.grantType), 249 | URLQueryItem(key: .deviceCode, value: deviceCode.deviceCode) 250 | ] 251 | 252 | if let scope = provider.scope { 253 | queryItems.append(URLQueryItem(key: .scope, value: scope.joined(separator: " "))) 254 | } 255 | return queryItems 256 | } 257 | } 258 | } 259 | 260 | fileprivate extension URLQueryItem { 261 | 262 | /// Initializes the URLQueryItem with the request key 263 | /// - Parameters: 264 | /// - key: the request builder key 265 | /// - value: the value 266 | init(key: OAuth.Request.Key, value: String?) { 267 | self.init(name: key.rawValue, value: value) 268 | } 269 | } 270 | 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://github.com/codefiesta/OAuthKit/actions/workflows/swift.yml/badge.svg) 2 | ![Swift 6.2+](https://img.shields.io/badge/Swift-6.2%2B-gold.svg) 3 | ![Xcode 26.0+](https://img.shields.io/badge/Xcode-26.0%2B-tomato.svg) 4 | ![iOS 26.0+](https://img.shields.io/badge/iOS-26.0%2B-crimson.svg) 5 | ![macOS 26.0+](https://img.shields.io/badge/macOS-26.0%2B-skyblue.svg) 6 | ![tvOS 26.0+](https://img.shields.io/badge/tvOS-26.0%2B-blue.svg) 7 | ![visionOS 26.0+](https://img.shields.io/badge/visionOS-26.0%2B-violet.svg) 8 | ![watchOS 26.0+](https://img.shields.io/badge/watchOS-26.0%2B-magenta.svg) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-indigo.svg)](https://opensource.org/licenses/MIT) 10 | ![Code Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/codefiesta/87655b6e3c89b9198287b2fefbfa641f/raw/oauthkit-coverage.json) 11 | 12 | # OAuthKit 13 | 14 | 15 | OAuthKit is a contemporary, event-driven Swift Package that utilizes the [Observation](https://developer.apple.com/documentation/observation) Framework to implement the observer design pattern and publish [OAuth 2.0](https://oauth.net/2/) events. This enables application developers to effortlessly configure OAuth Providers and concentrate on developing exceptional applications rather than being preoccupied with the intricacies of authorization flows. 16 |
17 | 18 | ## OAuthKit Features 19 | 20 | OAuthKit is a small, lightweight package that provides out of the box [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) safety support and [Observable](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/oauth/state-swift.enum) OAuth 2.0 state events that allow fine grained control over when and how to start authorization flows. 21 | 22 | Key features include: 23 | 24 | - [Simple Configuration](#oauthkit-configuration) 25 | - [Keychain protection with biometrics or companion device](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/configuration#Keychain-Protection) 26 | - [Private Browsing with non-persistent WebKit Datastores](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/configuration#Private-Browsing) 27 | - [Custom URLSession](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/configuration#URL-Session) configuration for complete control custom protocol specific data 28 | - [Observable State](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/gettingstarted#Observing-OAuth-State) driven events to allow full control over when and if users are prompted to authenticate with an OAuth provider 29 | - Supports all Apple Platforms (iOS, macOS, tvOS, visionOS, watchOS) 30 | - [Support for every OAuth 2.0 Flow](#oauthkit-authorization-flows) 31 | - [Authorization Code](#oauth-20-authorization-code-flow) 32 | - [PKCE](#oauth-20-pkce-flow) 33 | - [Device Code](#oauth-20-device-code-flow) 34 | - [Client Credentials](#oauth-20-client-credentials-flow) 35 | - [OpenID Connect](https://www.oauth.com/playground/oidc.html) 36 | 37 | ## OAuthKit Installation 38 | 39 | OAuthKit can be installed using [Swift Package Manager](https://www.swift.org/documentation/package-manager/). If you need to build with Swift Tools `6.1` and Apple APIs > `26.0` use version [1.5.1](https://github.com/codefiesta/OAuthKit/releases/tag/1.5.1). 40 | 41 | ```swift 42 | dependencies: [ 43 | .package(url: "https://github.com/codefiesta/OAuthKit", from: "2.0.1") 44 | ] 45 | ``` 46 | 47 | ## OAuthKit Usage 48 | 49 | The following is an example of the simplest usage of using OAuthKit across multiple platforms (iOS, macOS, visionOS, tvOS, watchOS): 50 | 51 | ```swift 52 | import OAuthKit 53 | import SwiftUI 54 | 55 | @main 56 | struct OAuthApp: App { 57 | 58 | @Environment(\.oauth) 59 | var oauth: OAuth 60 | 61 | /// Build the scene body 62 | var body: some Scene { 63 | 64 | WindowGroup { 65 | ContentView() 66 | } 67 | 68 | #if canImport(WebKit) 69 | WindowGroup(id: "oauth") { 70 | OAWebView(oauth: oauth) 71 | } 72 | #endif 73 | } 74 | } 75 | 76 | struct ContentView: View { 77 | 78 | @Environment(\.oauth) 79 | var oauth: OAuth 80 | 81 | #if canImport(WebKit) 82 | @Environment(\.openWindow) 83 | var openWindow 84 | 85 | @Environment(\.dismissWindow) 86 | private var dismissWindow 87 | #endif 88 | 89 | /// The view body that reacts to oauth state changes 90 | var body: some View { 91 | VStack { 92 | switch oauth.state { 93 | case .empty: 94 | providerList 95 | case .authorizing(let provider, let grantType): 96 | Text("Authorizing [\(provider.id)] with [\(grantType.rawValue)]") 97 | case .requestingAccessToken(let provider): 98 | Text("Requesting Access Token [\(provider.id)]") 99 | case .requestingDeviceCode(let provider): 100 | Text("Requesting Device Code [\(provider.id)]") 101 | case .authorized(let provider, _): 102 | Button("Authorized [\(provider.id)]") { 103 | oauth.clear() 104 | } 105 | case .receivedDeviceCode(_, let deviceCode): 106 | Text("To login, visit") 107 | Text(.init("[\(deviceCode.verificationUri)](\(deviceCode.verificationUri))")) 108 | .foregroundStyle(.blue) 109 | Text("and enter the following code:") 110 | Text(deviceCode.userCode) 111 | .padding() 112 | .border(Color.primary) 113 | .font(.title) 114 | case .error(let provider, let error): 115 | Text("Error [\(provider.id)]: \(error.localizedDescription)") 116 | } 117 | } 118 | .onChange(of: oauth.state) { _, state in 119 | handle(state: state) 120 | } 121 | } 122 | 123 | /// Displays a list of oauth providers. 124 | var providerList: some View { 125 | List(oauth.providers) { provider in 126 | Button(provider.id) { 127 | authorize(provider: provider) 128 | } 129 | } 130 | } 131 | 132 | /// Starts the authorization process for the specified provider. 133 | /// - Parameter provider: the provider to begin authorization for 134 | private func authorize(provider: OAuth.Provider) { 135 | #if canImport(WebKit) 136 | // Use the PKCE grantType for iOS, macOS, visionOS 137 | let grantType: OAuth.GrantType = .pkce(.init()) 138 | #else 139 | // Use the Device Code grantType for tvOS, watchOS 140 | let grantType: OAuth.GrantType = .deviceCode 141 | #endif 142 | 143 | // Start the authorization flow 144 | oauth.authorize(provider: provider, grantType: grantType) 145 | } 146 | 147 | /// Reacts to oauth state changes by opening or closing authorization windows. 148 | /// - Parameter state: the published state change 149 | private func handle(state: OAuth.State) { 150 | #if canImport(WebKit) 151 | switch state { 152 | case .empty, .error, .requestingAccessToken, .requestingDeviceCode: 153 | break 154 | case .authorizing, .receivedDeviceCode: 155 | openWindow(id: "oauth") 156 | case .authorized(_, _): 157 | dismissWindow(id: "oauth") 158 | } 159 | #endif 160 | } 161 | } 162 | ``` 163 | 164 | ## OAuthKit Configuration 165 | By default, the easiest way to configure OAuthKit is to simply drop an `oauth.json` file into your main bundle and it will get automatically loaded into your swift application and available as an [Environment](https://developer.apple.com/documentation/swiftui/environment) property wrapper. You can find an example `oauth.json` file [here](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). OAuthKit provides flexible constructor options that allows developers to customize how their oauth client is initialized and what features they want to implement. See the [oauth.init(\_:bundle:options)](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/oauth) method for details. 166 | 167 | ### OAuth initialized from main bundle (default) 168 | ```swift 169 | @Environment(\.oauth) 170 | var oauth: OAuth 171 | ``` 172 | ### OAuth initialized from specified bundle 173 | If you want to customize your OAuth environment or are using modules in your application, you can also specify which bundle to load configure files from: 174 | 175 | ```swift 176 | let oauth: OAuth = .init(.module) 177 | ``` 178 | 179 | ### OAuth initialized with providers 180 | If you are building your OAuth Providers programatically (recommended for production applications via a CI build pipeline for security purposes), you can pass providers and options as well. 181 | 182 | ```swift 183 | let providers: [OAuth.Provider] = ... 184 | let options: [OAuth.Option: Any] = [ 185 | .applicationTag: "com.bundle.identifier", 186 | .autoRefresh: true, 187 | .useNonPersistentWebDataStore: true 188 | ] 189 | let oauth: OAuth = .init(providers: providers, options: options) 190 | ``` 191 | 192 | ### OAuth initialized with custom URLSession 193 | To support custom protocols or URL schemes that your app supports, developers can pass a custom **.urlSession** option that will allow the configuration of custom [URLProtocol](https://developer.apple.com/documentation/foundation/urlprotocol) classes that can handle the loading of protocol-specific URL data. 194 | 195 | ```swift 196 | // Custom URLSession 197 | let configuration: URLSessionConfiguration = .ephemeral 198 | configuration.protocolClasses = [CustomURLProtocol.self] 199 | let urlSession: URLSession = .init(configuration: configuration) 200 | 201 | let options: [OAuth.Option: Any] = [.urlSession: urlSession] 202 | let oauth: OAuth = .init(.main, options: options) 203 | ``` 204 | 205 | ### OAuth initialized with Keychain protection and Private Browsing 206 | OAuthKit allows you to protect access to your keychain items with biometrics until successful local authentication. If the **.requireAuthenticationWithBiometricsOrCompanion** option is set to true, the device owner will need to be authenticated by biometry or a companion device before keychain items (tokens) can be accessed. OAuthKit uses a default [LAContext](https://developer.apple.com/documentation/localauthentication/lacontext), but if you need fine-grained control while evaluating a user’s identity, pass your own custom [LAContext](https://developer.apple.com/documentation/localauthentication/lacontext) to the options. 207 | 208 | Developers can also implement private browsing by setting the **.useNonPersistentWebDataStore** option to true. This forces the [WKWebView](https://developer.apple.com/documentation/webkit/wkwebview) used during authorization flows to use a non-persistent data store, preventing data from being written to the file system. 209 | 210 | 211 | ```swift 212 | // Custom LAContext 213 | let localAuthentication: LAContext = .init() 214 | localAuthentication.localizedReason = "read tokens from keychain" 215 | localAuthentication.localizedFallbackTitle = "Use password" 216 | localAuthentication.touchIDAuthenticationAllowableReuseDuration = 10 217 | 218 | let options: [OAuth.Option: Any] = [ 219 | .localAuthentication: localAuthentication, 220 | .requireAuthenticationWithBiometricsOrCompanion: true, 221 | .useNonPersistentWebDataStore: true, 222 | ] 223 | let oauth: OAuth = .init(.main, options: options) 224 | ``` 225 | 226 | ## OAuthKit Authorization Flows 227 | OAuth 2.0 authorization flows are started by calling the [oauth.authorize(provider:grantType:)](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/oauth#Starting-an-authorization-flow) method. 228 | 229 | A good resource to help understand the detailed steps involved in OAuth 2.0 authorization flows can be found on the [OAuth 2.0 Playground](https://www.oauth.com/playground/index.html). 230 | 231 | ```swift 232 | oauth.authorize(provider: provider, grantType: grantType) 233 | ``` 234 | 235 | 236 | ### OAuth 2.0 Authorization Code Flow 237 | The [Authorization Code](https://oauth.net/2/grant-types/authorization-code/) grant type is used by confidential and public clients to exchange an authorization code for an access token. It is recommended that all clients use the [PKCE](#oauth-20-pkce-flow) extension with this flow as well to provide better security. 238 | 239 | ```swift 240 | // Generate a state and set the GrantType 241 | let state: String = .secureRandom(32) // See String+Extensions 242 | let grantType: OAuth.GrantType = .authorizationCode(state) 243 | oauth.authorize(provider: provider, grantType: grantType) 244 | ``` 245 | 246 | ### OAuth 2.0 PKCE Flow 247 | PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) is an extension to the [Authorization Code](https://oauth.net/2/grant-types/authorization-code/) flow to prevent CSRF and authorization code injection attacks. 248 | 249 | Proof Key for Code Exchange ([PKCE](https://oauth.net/2/pkce/)) is the default and recommended flow to use in OAuthKit as this technique involves the client first creating a secret on each authorization request, and then using that secret again when exchanging the authorization code for an access token. This way if the code is intercepted, it will not be useful since the token request relies on the initial secret. 250 | 251 | ```swift 252 | // PKCE is the default workflow with an auto generated pkce object 253 | oauth.authorize(provider: provider) 254 | 255 | // Or you can specify the workflow to use PKCE and inject your own values 256 | let grantType: OAuth.GrantType = .pkce(.init()) 257 | oauth.authorize(provider: provider, grantType: grantType) 258 | ``` 259 | 260 | ### OAuth 2.0 Device Code Flow 261 | OAuthKit 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](https://github.com/codefiesta/OAuthKit/blob/main/Sources/OAuthKit/OAuth+Provider.swift). 262 | 263 | ```swift 264 | let grantType: OAuth.GrantType = .deviceCode 265 | oauth.authorize(provider: provider, grantType: grantType) 266 | ``` 267 | 268 | ![tvOS-screenshot](https://github.com/user-attachments/assets/14997164-f86a-4ee0-b6b7-8c0d9732c83e) 269 | 270 | ### OAuth 2.0 Client Credentials Flow 271 | 272 | The OAuth 2.0 Client Credentials flow is a mechanism where a client application authenticates itself to an authorization server using its own credentials rather than a user's credentials. This flow is primarily used in server-to-server communication, where a service or application needs to access a protected resource without involving a user. 273 | 274 | ```swift 275 | let grantType: OAuth.GrantType = .clientCredentials 276 | oauth.authorize(provider: provider, grantType: grantType) 277 | ``` 278 | 279 | ## OAuth 2.0 Provider Debugging 280 | Debugging output with [debugPrint(\_:separator:terminator:)](https://developer.apple.com/documentation/swift/debugprint(_:separator:terminator:)) into the standard output is disabled by default. If you need to inspect response data received from [providers](https://github.com/codefiesta/OAuthKit/blob/main/Sources/OAuthKit/OAuth+Provider.swift), you can toggle the `debug` value to true. You can see an [example here](https://github.com/codefiesta/OAuthKit/blob/main/Tests/OAuthKitTests/Resources/oauth.json). 281 | 282 | ## OAuthKit Sample Application 283 | You can find a sample application integrated with OAuthKit [here](https://github.com/codefiesta/OAuthSample). 284 | 285 | ## Security Best Practices 286 | 1. Use the [PKCE](https://github.com/codefiesta/OAuthKit?tab=readme-ov-file#oauth-20-pkce-flow) workflow if possible in your public applications. 287 | 2. Never check in **clientID** or **clientSecret** values into source control. Although the **clientID** is public and the **clientSecret** is sensitive and private it is still widely regarded that *both* of these values should be always be treated as confidential. 288 | 3. Don't include `oauth.json` files in your publicly distributed applications. It is possible for someone to [inspect and reverse engineer](https://www.nowsecure.com/blog/2021/09/08/basics-of-reverse-engineering-ios-mobile-apps/) the contents of your app and look at any files inside your app bundle which means you could potentially expose any confidential values contained in this file. 289 | 4. Build OAuth Providers Programmatically via your CI Build Pipeline. Most continuous integration and delivery platforms have the ability to generate source code during build workflows that can get compiled into Swift byte code. It's should be feasible to write a step in the CI pipeline that generates a .swift file that provides access to a list of OAuth.Provider objects that have their confidential values set from the secure CI platform secret keys. This swift code can then compiled into the application as byte code. In practical terms, the security and obfuscation inherent in compiled languages make extracting confidential values difficult (but not impossible). 290 | 5. OAuth 2.0 providers shouldn't provide the ability for publicly distributed applications to initiate [Client Credentials](https://github.com/codefiesta/OAuthKit?tab=readme-ov-file#oauth-20-client-credentials-flow) workflows since it is possible for someone to extract your secrets. 291 | 292 | 293 | ## OAuth 2.0 Providers 294 | OAuthKit should work with any standard OAuth2 provider. Below is a list of tested providers along with their OAuth2 documentation links. If you’re interested in seeing support or examples for a provider not listed here, please open an issue on our [here](https://github.com/codefiesta/OAuthKit/issues). 295 | 296 | * [Auth0 / Okta](https://developer.okta.com/signup/) 297 | * [Box](https://developer.box.com/guides/authentication/oauth2/) 298 | * [Dropbox](https://developers.dropbox.com/oauth-guide) 299 | * [Github](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) 300 | * [Google](https://developers.google.com/identity/protocols/oauth2) 301 | * **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). 302 | * [LinkedIn](https://developer.linkedin.com/) 303 | * **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). 304 | * LinkedIn currently doesn't support **PKCE**. 305 | * [Instagram](https://developers.facebook.com/docs/instagram-basic-display-api/guides/getting-access-tokens-and-permissions) 306 | * [Microsoft](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) 307 | * **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. 308 | * [Slack](https://api.slack.com/authentication/oauth-v2) 309 | * **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. 310 | * [Stripe](https://docs.stripe.com/stripe-apps/api-authentication/oauth) 311 | * [Twitter](https://developer.x.com/en/docs/authentication/oauth-2-0) 312 | * **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. 313 | 314 | ## OAuthKit Documentation 315 | 316 | You can find the complete Swift DocC documentation for the [OAuthKit Framework here](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/). 317 | 318 | -------------------------------------------------------------------------------- /Sources/OAuthKit/OAuth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuth.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | import Foundation 8 | #if canImport(LocalAuthentication) 9 | import LocalAuthentication 10 | #endif 11 | import Observation 12 | 13 | /// The default file name that holds the list of providers. 14 | private let defaultResourceName = "oauth" 15 | /// The default file extension. 16 | private let defaultExtension = "json" 17 | /// The default reason for local authentication with biometrics or companion device. 18 | private let defaultAuthenticationWithBiometricsOrCompanionReason = "unlock keychain" 19 | 20 | /// Provides an enum of oauth errors. 21 | public enum OAError: Error { 22 | /// An error occurred while building a request url 23 | case malformedURL 24 | /// An error occurred while loading data from a request 25 | case badResponse 26 | /// Unable to decode a response from a provider into an expected type. 27 | case decoding 28 | /// Unable to write data to the keychain 29 | case keychain 30 | } 31 | 32 | /// Provides an `Observable` OAuth 2.0 implementation that emits ``OAuth/state`` changes when 33 | /// an authorization flow is started by calling ``OAuth/authorize(provider:grantType:)``. 34 | @MainActor 35 | @Observable 36 | public final class OAuth: Sendable { 37 | 38 | /// An observable list of available OAuth providers to choose from. 39 | public var providers = [Provider]() 40 | 41 | /// An observable oauth state. 42 | public var state: State = .empty 43 | 44 | /// The url session to use for communicating with providers. 45 | @ObservationIgnored 46 | public var urlSession: URLSession = .init(configuration: .ephemeral) 47 | 48 | @ObservationIgnored 49 | var keychain: Keychain = .default 50 | 51 | #if os(macOS) || os(iOS) || os(visionOS) 52 | @ObservationIgnored 53 | var context: LAContext = .init() 54 | #endif 55 | 56 | @ObservationIgnored 57 | private var tasks = [Task<(), any Error>]() 58 | 59 | @ObservationIgnored 60 | private let networkMonitor: NetworkMonitor = .shared 61 | 62 | /// Configuration option determining if tokens should be auto refreshed or not. 63 | @ObservationIgnored 64 | private var autoRefresh: Bool = false 65 | 66 | /// Configuration option determining if the WKWebsiteDataStore used during authorization flows should use an ephemeral datastore. 67 | /// Set to true if you wish to implement private browsing and force a new login attempt every time an authorization flow is started. 68 | @ObservationIgnored 69 | var useNonPersistentWebDataStore: Bool = false 70 | 71 | /// Configuration option determining if the keychain should be protected with biometrics until sucessful local authentication. 72 | /// If set to true, the device owner will need to be authenticated by biometry or a companion device before the keychain items can be accessed. 73 | @ObservationIgnored 74 | var requireAuthenticationWithBiometricsOrCompanion: Bool = false 75 | 76 | /// The json decoder 77 | @ObservationIgnored 78 | private let decoder: JSONDecoder = .init() 79 | 80 | /// Initializes the OAuth service with the specified providers and configuration options. 81 | /// - Parameters: 82 | /// - providers: the list of oauth providers 83 | /// - options: the configuration options to apply 84 | public init(providers: [Provider] = [Provider](), options: [Option: Any]? = nil) { 85 | self.providers = providers 86 | configure(options) 87 | } 88 | 89 | /// Common Initializer that attempts to load an `oauth.json` file from the specified bundle. 90 | /// - Parameters: 91 | /// - bundle: the bundle to load the oauth provider configuration information from. 92 | /// - options: the configuration options to apply 93 | public init(_ bundle: Bundle, options: [Option: Any]? = nil) { 94 | self.providers = loadProviders(bundle) 95 | configure(options) 96 | } 97 | } 98 | 99 | public extension OAuth { 100 | 101 | /// Generates a cryptographically secure random Base 64 URL encoded string. 102 | /// - Parameter count: the byte count 103 | /// - Returns: a cryptographically secure random Base 64 URL encoded string 104 | static func secureRandom(count: Int = 32) -> String { 105 | .secureRandom(count: count) 106 | } 107 | 108 | /// Starts the authorization process for the specified provider. 109 | /// - Parameters: 110 | /// - provider: the provider to begin authorization for 111 | /// - grantType: the grant type to execute 112 | func authorize(provider: Provider, grantType: GrantType = .pkce(.init())) { 113 | switch grantType { 114 | case .authorizationCode: 115 | state = .authorizing(provider, grantType) 116 | case .pkce: 117 | state = .authorizing(provider, grantType) 118 | case .deviceCode: 119 | state = .requestingDeviceCode(provider) 120 | Task.immediate { 121 | await requestDeviceCode(provider: provider) 122 | } 123 | case .clientCredentials: 124 | Task.immediate { 125 | await requestClientCredentials(provider: provider) 126 | } 127 | case .refreshToken: 128 | Task.immediate { 129 | await refreshToken(provider: provider) 130 | } 131 | } 132 | } 133 | 134 | /// Requests to exchange a code for an access token. 135 | /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 136 | /// - Parameters: 137 | /// - provider: the provider the access token is being requested from 138 | /// - code: the code to exchange 139 | /// - pkce: the pkce data 140 | func token(provider: Provider, code: String, pkce: PKCE? = nil) { 141 | Task.immediate { 142 | await requestToken(provider: provider, code: code, pkce: pkce) 143 | } 144 | } 145 | 146 | /// Removes all tokens and clears the OAuth state 147 | func clear() { 148 | debugPrint("⚠️ [Clearing oauth state]") 149 | keychain.clear() 150 | state = .empty 151 | } 152 | } 153 | 154 | // MARK: Private 155 | 156 | private extension OAuth { 157 | 158 | /// Loads providers from the specified bundle. 159 | /// - Parameter bundle: the bundle to load the oauth provider configuration information from. 160 | /// - Returns: found providers in the specifed bundle or an empty list if not found 161 | func loadProviders(_ bundle: Bundle) -> [Provider] { 162 | guard let url = bundle.url(forResource: defaultResourceName, withExtension: defaultExtension), 163 | let data = try? Data(contentsOf: url), 164 | let providers = try? decoder.decode([Provider].self, from: data) else { 165 | return [] 166 | } 167 | return providers 168 | } 169 | 170 | /// Loads authorizations from the keychain. 171 | func loadAuthorizations() { 172 | for provider in providers { 173 | if let authorization: OAuth.Authorization = try? keychain.get(key: provider.id) { 174 | publish(state: .authorized(provider, authorization)) 175 | } 176 | } 177 | } 178 | 179 | /// Configures the oauth client from options. 180 | /// - Parameter options: the options to apply to this oauth client 181 | func configure(_ options: [Option: Any]?) { 182 | // Override from options 183 | if let options { 184 | 185 | // Override token auto refresh 186 | if let autoRefresh = options[.autoRefresh] as? Bool { 187 | self.autoRefresh = autoRefresh 188 | } 189 | 190 | // Ephemeral web data store 191 | if let useNonPersistentWebDataStore = options[.useNonPersistentWebDataStore] as? Bool { 192 | self.useNonPersistentWebDataStore = useNonPersistentWebDataStore 193 | } 194 | 195 | // Keychain protection with biometrics or companion device 196 | if let requireAuthenticationWithBiometricsOrCompanion = options[.requireAuthenticationWithBiometricsOrCompanion] as? Bool { 197 | self.requireAuthenticationWithBiometricsOrCompanion = requireAuthenticationWithBiometricsOrCompanion 198 | } 199 | 200 | // Override the local authentication context 201 | #if os(macOS) || os(iOS) || os(visionOS) 202 | if let context = options[.localAuthentication] as? LAContext { 203 | self.context = context 204 | } 205 | #endif 206 | 207 | // Override the url session 208 | if let urlSession = options[.urlSession] as? URLSession { 209 | self.urlSession = urlSession 210 | } 211 | 212 | // Override the keychain to use the custom application tag 213 | if let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { 214 | self.keychain = .init(applicationTag) 215 | } 216 | } 217 | monitor() 218 | restore() 219 | } 220 | 221 | /// Restores state from storage. If the keychain is protected by biometrics with local authentication, then 222 | /// the device owner needs to authenticate with biometrics or companion app before any tokens in the keychain can be accessed. 223 | func restore() { 224 | if requireAuthenticationWithBiometricsOrCompanion { 225 | authenticateWithBiometricsOrCompanion() 226 | } else { 227 | loadAuthorizations() 228 | } 229 | } 230 | 231 | /// Device owner will be authenticated by biometry or a companion device e.g. watch, mac, etc. 232 | func authenticateWithBiometricsOrCompanion() { 233 | 234 | #if os(macOS) || os(iOS) || os(visionOS) 235 | let localizedReason = context.localizedReason.isNotEmpty ? context.localizedReason: defaultAuthenticationWithBiometricsOrCompanionReason 236 | #if os(macOS) || os(iOS) 237 | let policy: LAPolicy = .deviceOwnerAuthenticationWithBiometricsOrCompanion 238 | #else 239 | let policy: LAPolicy = .deviceOwnerAuthenticationWithBiometrics 240 | #endif 241 | var error: NSError? 242 | if context.canEvaluatePolicy(policy, error: &error) { 243 | context.evaluatePolicy(policy, localizedReason: localizedReason) { [weak self] success, error in 244 | guard let self else { return } 245 | Task.immediate { @MainActor in 246 | if success { 247 | self.loadAuthorizations() 248 | } 249 | } 250 | } 251 | } 252 | #else 253 | debugPrint("⚠️ Misconfigured option: `requireAuthenticationWithBiometricsOrCompanion` is set to true but the current platform does not support biometric authentication.") 254 | loadAuthorizations() 255 | #endif 256 | } 257 | 258 | /// Starts the network monitor. 259 | func monitor() { 260 | Task { 261 | await networkMonitor.start() 262 | } 263 | } 264 | 265 | /// Publishes state on the main thread. 266 | /// - Parameter state: the new state information to publish out on the main thread. 267 | func publish(state: State) { 268 | switch state { 269 | case .authorized(let provider, let auth): 270 | schedule(provider: provider, auth: auth) 271 | case .receivedDeviceCode(let provider, let deviceCode): 272 | schedule(provider: provider, deviceCode: deviceCode) 273 | case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode: 274 | break 275 | } 276 | self.state = state 277 | } 278 | 279 | /// Schedules the provider to be polled for authorization with the specified device token. 280 | /// - Parameters: 281 | /// - provider: the oauth provider 282 | /// - deviceCode: the device code issued by the provider 283 | func schedule(provider: Provider, deviceCode: DeviceCode) { 284 | let timeInterval: TimeInterval = .init(deviceCode.interval) 285 | let task = Task.delayed(timeInterval: timeInterval) { [weak self] in 286 | guard let self else { return } 287 | await self.poll(provider: provider, deviceCode: deviceCode) 288 | } 289 | tasks.append(task) 290 | } 291 | 292 | /// Schedules refresh tasks for the specified authorization. 293 | /// - Parameters: 294 | /// - provider: the oauth provider 295 | /// - auth: the authentication to schedule a future tasks for 296 | func schedule(provider: Provider, auth: Authorization) { 297 | // Don't bother scheduling a task for tokens that can't refresh 298 | guard let _ = auth.token.refreshToken else { return } 299 | 300 | if autoRefresh { 301 | if let expiration = auth.expiration { 302 | let timeInterval = expiration - Date.now 303 | if timeInterval > 0 { 304 | // Schedule the auto refresh task 305 | let task = Task.delayed(timeInterval: timeInterval) { [weak self] in 306 | guard let self else { return } 307 | await self.refreshToken(provider: provider) 308 | } 309 | tasks.append(task) 310 | } else { 311 | // Execute the task immediately 312 | Task.immediate { 313 | await refreshToken(provider: provider) 314 | } 315 | } 316 | } 317 | } 318 | } 319 | } 320 | 321 | // MARK: URLRequests 322 | 323 | extension OAuth { 324 | 325 | /// Requests to exchange a code for an access token. 326 | /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 327 | /// - Parameters: 328 | /// - provider: the provider the access token is being requested from 329 | /// - code: the code to exchange 330 | /// - pkce: the PKCE data to pass along with the request 331 | func requestToken(provider: Provider, code: String, pkce: PKCE? = nil) async { 332 | // Publish the state 333 | publish(state: .requestingAccessToken(provider)) 334 | 335 | guard let request = Request.token(provider: provider, code: code, pkce: pkce) else { 336 | return publish(state: .error(provider, .malformedURL)) 337 | } 338 | 339 | guard let (data, response) = try? await urlSession.data(for: request) else { 340 | return publish(state: .error(provider, .badResponse)) 341 | } 342 | 343 | if provider.debug { 344 | let statusCode = response.statusCode() ?? -1 345 | let rawData = String(data: data, encoding: .utf8) ?? .empty 346 | debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") 347 | } 348 | 349 | // Decode the token 350 | guard let token = try? decoder.decode(Token.self, from: data) else { 351 | return publish(state: .error(provider, .decoding)) 352 | } 353 | 354 | // Store the authorization 355 | let authorization = Authorization(issuer: provider.id, token: token) 356 | guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { 357 | return publish(state: .error(provider, .keychain)) 358 | } 359 | 360 | publish(state: .authorized(provider, authorization)) 361 | } 362 | 363 | /// Refreshes the token for the specified provider. 364 | /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-6 365 | /// - Parameters: 366 | /// - provider: the provider to request a refresh token for 367 | func refreshToken(provider: Provider) async { 368 | guard let auth: OAuth.Authorization = try? keychain.get(key: provider.id) else { return } 369 | 370 | // If we can't build a refresh request simply bail as no refresh token 371 | // was returned in the original auth request 372 | guard let request = Request.refresh(provider: provider, token: auth.token) else { 373 | if auth.isExpired { clear() } 374 | return 375 | } 376 | 377 | guard let (data, response) = try? await urlSession.data(for: request) else { 378 | return publish(state: .error(provider, .badResponse)) 379 | } 380 | 381 | if provider.debug { 382 | let statusCode = response.statusCode() ?? -1 383 | let rawData = String(data: data, encoding: .utf8) ?? .empty 384 | debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") 385 | } 386 | 387 | // Decode the token 388 | guard response.isOK, let token = try? decoder.decode(Token.self, from: data) else { 389 | return publish(state: .error(provider, .decoding)) 390 | } 391 | 392 | // Store the authorization 393 | let authorization = Authorization(issuer: provider.id, token: token) 394 | guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { 395 | return publish(state: .error(provider, .keychain)) 396 | } 397 | publish(state: .authorized(provider, authorization)) 398 | } 399 | 400 | /// Requests a device code from the specified provider. 401 | /// - Parameters: 402 | /// - provider: the provider the device code is being requested from 403 | func requestDeviceCode(provider: Provider) async { 404 | guard let request = Request.device(provider: provider) else { return } 405 | guard let (data, response) = try? await urlSession.data(for: request) else { 406 | return publish(state: .error(provider, .badResponse)) 407 | } 408 | 409 | if provider.debug { 410 | let statusCode = response.statusCode() ?? -1 411 | let rawData = String(data: data, encoding: .utf8) ?? .empty 412 | debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") 413 | } 414 | 415 | // Decode the device code 416 | guard let deviceCode = try? decoder.decode(DeviceCode.self, from: data) else { 417 | return publish(state: .error(provider, .decoding)) 418 | } 419 | 420 | // Publish the state 421 | publish(state: .receivedDeviceCode(provider, deviceCode)) 422 | } 423 | 424 | /// Makes a client credentials request grant request from the specified provider. 425 | /// - Parameters: 426 | /// - provider: the provider the device code is being requested from 427 | func requestClientCredentials(provider: Provider) async { 428 | guard let request = Request.token(provider: provider) else { return } 429 | guard let (data, response) = try? await urlSession.data(for: request) else { 430 | return publish(state: .error(provider, .badResponse)) 431 | } 432 | 433 | if provider.debug { 434 | let statusCode = response.statusCode() ?? -1 435 | let rawData = String(data: data, encoding: .utf8) ?? .empty 436 | debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") 437 | } 438 | 439 | // Decode the token 440 | guard let token = try? decoder.decode(Token.self, from: data) else { 441 | return publish(state: .error(provider, .decoding)) 442 | } 443 | 444 | // Store the authorization 445 | let authorization = Authorization(issuer: provider.id, token: token) 446 | guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { 447 | return publish(state: .error(provider, .keychain)) 448 | } 449 | publish(state: .authorized(provider, authorization)) 450 | } 451 | 452 | /// Polls the oauth provider's access token endpoint until the device code has expired or we've successfully received an auth token. 453 | /// See: https://oauth.net/2/grant-types/device-code/ 454 | /// - Parameters: 455 | /// - provider: the provider to poll 456 | /// - deviceCode: the device code to use 457 | func poll(provider: Provider, deviceCode: DeviceCode) async { 458 | 459 | guard !deviceCode.isExpired, let request = Request.token(provider: provider, deviceCode: deviceCode) else { 460 | return publish(state: .error(provider, .malformedURL)) 461 | } 462 | 463 | guard let (data, response) = try? await urlSession.data(for: request) else { 464 | return publish(state: .error(provider, .badResponse)) 465 | } 466 | 467 | if provider.debug { 468 | let statusCode = response.statusCode() ?? -1 469 | let rawData = String(data: data, encoding: .utf8) ?? .empty 470 | debugPrint("Response: [\(statusCode))]", "Data: [\(rawData)]") 471 | } 472 | 473 | /// If we received something other than a 200 response or we can't decode the token then restart the polling 474 | guard response.isOK, let token = try? decoder.decode(Token.self, from: data) else { 475 | // Reschedule the polling task 476 | return schedule(provider: provider, deviceCode: deviceCode) 477 | } 478 | 479 | // Store the authorization 480 | let authorization = Authorization(issuer: provider.id, token: token) 481 | guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { 482 | return publish(state: .error(provider, .keychain)) 483 | } 484 | publish(state: .authorized(provider, authorization)) 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /Tests/OAuthKitTests/OAuthTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OAuthTests.swift 3 | // 4 | // 5 | // Created by Kevin McKee 6 | // 7 | import Foundation 8 | #if canImport(LocalAuthentication) 9 | import LocalAuthentication 10 | #endif 11 | @testable import OAuthKit 12 | import Testing 13 | 14 | @MainActor 15 | @Suite("OAuth Tests", .tags(.oauth)) 16 | final class OAuthTests { 17 | 18 | let oauth: OAuth 19 | let tag: String 20 | var keychain: Keychain { 21 | oauth.keychain 22 | } 23 | var provider: OAuth.Provider { 24 | oauth.providers.filter{ $0.id == "GitHub" }.first! 25 | } 26 | 27 | /// The mock url session that overrides the protocol classes with `OAuthTestURLProtocol` 28 | /// that will intercept all outbound requests and return mocked test data. 29 | private static let urlSession: URLSession = { 30 | let configuration: URLSessionConfiguration = .ephemeral 31 | configuration.protocolClasses = [OAuthTestURLProtocol.self] 32 | return .init(configuration: configuration) 33 | }() 34 | 35 | /// Initializer. 36 | init() async throws { 37 | tag = "oauthkit.test." + .secureRandom() 38 | 39 | let options: [OAuth.Option: Any] = [ 40 | .applicationTag: tag, .autoRefresh: true, 41 | .useNonPersistentWebDataStore: true, 42 | .urlSession: Self.urlSession, 43 | ] 44 | oauth = .init(.module, options: options) 45 | #expect(oauth.useNonPersistentWebDataStore == true) 46 | #expect(oauth.urlSession == Self.urlSession) 47 | } 48 | 49 | /// Tests the initialization with providers. 50 | @Test("When Initializing") 51 | func whenInitializing() async throws { 52 | let appTag: String = .secureRandom() 53 | let options: [OAuth.Option: Any] = [ 54 | .applicationTag: appTag, 55 | .autoRefresh: true, 56 | .urlSession: Self.urlSession, 57 | ] 58 | let providers: [OAuth.Provider] = [ 59 | .init(id: .secureRandom(), 60 | authorizationURL: URL(string: "http://github.com/codefiesta/auth")!, 61 | accessTokenURL: URL(string: "http://github.com/codefiesta/token")!, 62 | clientID: .secureRandom(), 63 | clientSecret: .secureRandom()) 64 | ] 65 | let customOAuth: OAuth = .init(providers: providers, options: options) 66 | #expect(customOAuth.providers.count == 1) 67 | #expect(customOAuth.useNonPersistentWebDataStore == false) 68 | #expect(customOAuth.urlSession == Self.urlSession) 69 | } 70 | 71 | @Test("When Requiring Local Authentication") 72 | func whenRequiringAuthenticationWithBiometricsOrCompanion() async throws { 73 | let appTag: String = .secureRandom() 74 | var options: [OAuth.Option: Any] = [ 75 | .applicationTag: appTag, 76 | .requireAuthenticationWithBiometricsOrCompanion: true, 77 | .autoRefresh: true 78 | ] 79 | #if !os(tvOS) 80 | options[.localAuthentication] = OAuthTestLAContext() 81 | #endif 82 | let customOAuth: OAuth = .init(.module, options: options) 83 | #expect(customOAuth.providers.isNotEmpty) 84 | #expect(customOAuth.requireAuthenticationWithBiometricsOrCompanion == true) 85 | } 86 | 87 | @Test("When Restoring Authorizations") 88 | func whenRestoringAuthorizations() async throws { 89 | let key = provider.id 90 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") 91 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 92 | let inserted = try! keychain.set(auth, for: key) 93 | #expect(inserted == true) 94 | 95 | let options: [OAuth.Option: Any] = [ 96 | .applicationTag: tag, 97 | .autoRefresh: false, 98 | .useNonPersistentWebDataStore: true, 99 | .urlSession: Self.urlSession 100 | ] 101 | let restoredOAuth: OAuth = .init(.module, options: options) 102 | #expect(restoredOAuth.state == .authorized(provider, auth)) 103 | keychain.clear() 104 | } 105 | 106 | /// Tests the custom date extension operator. 107 | @Test("Expiring tokens") 108 | func whenExpiring() async throws { 109 | let expiresIn = 60 110 | let now = Date.now 111 | let issued = now.addingTimeInterval(-TimeInterval(expiresIn * 10)) // 10 minutes ago 112 | let expiration = issued.addingTimeInterval(TimeInterval(expiresIn)) 113 | let timeInterval = expiration - Date.now 114 | #expect(timeInterval < 0) 115 | } 116 | 117 | /// Tests the authorization request parameters. 118 | @Test("Building Authorization Request") 119 | func whenBuildingAuthorizationRequest() async throws { 120 | let state: String = .secureRandom(count: 16) 121 | let grantType: OAuth.GrantType = .authorizationCode(state) 122 | let request = OAuth.Request.auth(provider: provider, grantType: grantType) 123 | #expect(request != nil) 124 | #expect(request!.url!.absoluteString.contains("client_id=\(provider.clientID)")) 125 | #expect(request!.url!.absoluteString.contains("redirect_uri=\(provider.redirectURI!)")) 126 | #expect(request!.url!.absoluteString.contains("response_type=code")) 127 | #expect(request!.url!.absoluteString.contains("state=\(state)")) 128 | } 129 | 130 | /// Tests the building of client credential token requests. 131 | @Test("Building Client Credentials Token Requests") 132 | func whenBuildingClientCredentialsTokenRequests() async throws { 133 | let request = OAuth.Request.token(provider: provider) 134 | let data = request?.httpBody 135 | let stringData = String(data: data!, encoding: .utf8) 136 | #expect(request != nil) 137 | #expect(data != nil) 138 | #expect(stringData!.contains("client_id=")) 139 | #expect(stringData!.contains("client_secret=")) 140 | #expect(stringData!.contains("grant_type=client_credentials")) 141 | oauth.authorize(provider: provider, grantType: .clientCredentials) 142 | let result = await waitForAuthorization(oauth) 143 | #expect(result == true) 144 | } 145 | 146 | /// Tests the `/device`code request parameters. 147 | @Test("Building Device Code Request") 148 | func whenBuildingDeviceCodeRequest() async throws { 149 | let request = OAuth.Request.device(provider: provider) 150 | #expect(request != nil) 151 | #expect(request!.url!.absoluteString.contains("client_id=\(provider.clientID)")) 152 | #expect(request!.url!.absoluteString.contains("client_secret=\(provider.clientSecret!)")) 153 | #expect(request!.url!.absoluteString.contains("grant_type=device_code")) 154 | oauth.authorize(provider: provider, grantType: .deviceCode) 155 | } 156 | 157 | /// Tests the building of device code token requests. 158 | @Test("Building Device Code Token Requests") 159 | func whenBuildingDeviceCodeTokenRequests() async throws { 160 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: "ABC-XYZ", verificationUri: "https://example.com/device", expiresIn: 1800, interval: 5) 161 | let request = OAuth.Request.token(provider: provider, deviceCode: deviceCode) 162 | #expect(request != nil) 163 | #expect(request!.url!.absoluteString.contains("client_id=\(provider.clientID)")) 164 | #expect(request!.url!.absoluteString.contains("device_code=\(deviceCode.deviceCode)")) 165 | #expect(request!.url!.absoluteString.contains("grant_type=\(OAuth.DeviceCode.grantType)")) 166 | } 167 | 168 | /// Tests the building of token requests. 169 | @Test("Building Token Requests") 170 | func whenBuildingTokenRequests() async throws { 171 | let code: String = .secureRandom() 172 | let request = OAuth.Request.token(provider: provider, code: code, pkce: nil) 173 | let data = request?.httpBody 174 | let stringData = String(data: data!, encoding: .utf8) 175 | #expect(request != nil) 176 | #expect(data != nil) 177 | #expect(stringData!.contains("client_id=\(provider.clientID)")) 178 | #expect(stringData!.contains("client_secret=\(provider.clientSecret!)")) 179 | #expect(stringData!.contains("code=\(code)")) 180 | #expect(stringData!.contains("redirect_uri=\(provider.redirectURI!)")) 181 | #expect(stringData!.contains("grant_type=authorization_code")) 182 | oauth.token(provider: provider, code: code, pkce: nil) 183 | let result = await waitForAuthorization(oauth) 184 | #expect(result == true) 185 | } 186 | 187 | /// Tests the building of PKCE token requests. 188 | @Test("Building PKCE Token Requests") 189 | func whenBuildingPKCETokenRequests() async throws { 190 | let code: String = .secureRandom() 191 | let pkce: OAuth.PKCE = .init() 192 | let request = OAuth.Request.token(provider: provider, code: code, pkce: pkce) 193 | let data = request?.httpBody 194 | let stringData = String(data: data!, encoding: .utf8) 195 | #expect(request != nil) 196 | #expect(data != nil) 197 | #expect(stringData!.contains("client_id=\(provider.clientID)")) 198 | if let clientSecret = provider.clientSecret { 199 | #expect(stringData!.contains("client_secret=\(clientSecret)")) 200 | } 201 | #expect(stringData!.contains("code=\(code)")) 202 | #expect(stringData!.contains("redirect_uri=\(provider.redirectURI!)")) 203 | #expect(stringData!.contains("grant_type=authorization_code")) 204 | #expect(stringData!.contains("code_verifier=\(pkce.codeVerifier)")) 205 | oauth.token(provider: provider, code: code, pkce: pkce) 206 | let result = await waitForAuthorization(oauth) 207 | #expect(result == true) 208 | } 209 | 210 | /// Tests the refresh token request parameters. 211 | @Test("Building Refresh Token Request") 212 | func whenBuildingRefreshTokenRequest() async throws { 213 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: nil, type: "Bearer") 214 | let request = OAuth.Request.refresh(provider: provider, token: token) 215 | #expect(request != nil) 216 | #expect(request!.url!.absoluteString.contains("client_id=")) 217 | #expect(request!.url!.absoluteString.contains("grant_type=refresh_token")) 218 | #expect(request!.url!.absoluteString.contains("refresh_token=\(token.refreshToken!)")) 219 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 220 | try! keychain.set(auth, for: provider.id) 221 | oauth.authorize(provider: provider, grantType: .refreshToken) 222 | let result = await waitForAuthorization(oauth) 223 | #expect(result == true) 224 | } 225 | 226 | @Test("When Auto Refreshng Tokens") 227 | func whenAutoRefreshingTokens() async throws { 228 | let key = provider.id 229 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") 230 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 231 | let inserted = try! keychain.set(auth, for: key) 232 | #expect(inserted == true) 233 | 234 | let options: [OAuth.Option: Any] = [ 235 | .applicationTag: tag, 236 | .autoRefresh: true, 237 | .useNonPersistentWebDataStore: true, 238 | .urlSession: Self.urlSession 239 | ] 240 | let restoredOAuth: OAuth = .init(.module, options: options) 241 | #expect(restoredOAuth.state == .authorized(provider, auth)) 242 | restoredOAuth.state = .empty 243 | let result = await waitForAuthorization(restoredOAuth) 244 | #expect(result == true) 245 | } 246 | 247 | @Test("When Receiving bad response data") 248 | func whenReceivingBadResponseData() async throws { 249 | let configuration: URLSessionConfiguration = .ephemeral 250 | configuration.protocolClasses = [OAuthTestClientErrorURLProtocol.self] 251 | let urlSession: URLSession = .init(configuration: configuration) 252 | 253 | let applicationTag = "oauthkit.test." + .secureRandom() 254 | let options: [OAuth.Option: Any] = [ 255 | .applicationTag: applicationTag, 256 | .autoRefresh: true, 257 | .useNonPersistentWebDataStore: true, 258 | .urlSession: urlSession, 259 | ] 260 | 261 | // Token 262 | let oauth: OAuth = .init(.module, options: options) 263 | await oauth.requestToken(provider: provider, code: .secureRandom()) 264 | #expect(oauth.state == .error(provider, .decoding)) 265 | 266 | // Refresh Token 267 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") 268 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 269 | try! oauth.keychain.set(auth, for: provider.id) 270 | await oauth.refreshToken(provider: provider) 271 | #expect(oauth.state == .error(provider, .decoding)) 272 | oauth.clear() 273 | 274 | // Client Credentials 275 | await oauth.requestClientCredentials(provider: provider) 276 | #expect(oauth.state == .error(provider, .decoding)) 277 | 278 | // Device Code 279 | await oauth.requestDeviceCode(provider: provider) 280 | #expect(oauth.state == .error(provider, .decoding)) 281 | 282 | // Device Code Polling 283 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: .secureRandom(), verificationUri: "https://example.com", expiresIn: 200, interval: 0) 284 | await oauth.poll(provider: provider, deviceCode: deviceCode) 285 | #expect(oauth.state == .error(provider, .decoding)) 286 | } 287 | 288 | @Test("When Received server error") 289 | func whenReceivingServerError() async throws { 290 | let configuration: URLSessionConfiguration = .ephemeral 291 | configuration.protocolClasses = [OAuthTestServerErrorURLProtocol.self] 292 | let urlSession: URLSession = .init(configuration: configuration) 293 | 294 | let applicationTag = "oauthkit.test." + .secureRandom() 295 | let options: [OAuth.Option: Any] = [ 296 | .applicationTag: applicationTag, 297 | .autoRefresh: true, 298 | .useNonPersistentWebDataStore: true, 299 | .urlSession: urlSession, 300 | ] 301 | 302 | // Token 303 | let oauth: OAuth = .init(.module, options: options) 304 | await oauth.requestToken(provider: provider, code: .secureRandom()) 305 | #expect(oauth.state == .error(provider, .badResponse)) 306 | 307 | // Refresh Token 308 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") 309 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 310 | try! oauth.keychain.set(auth, for: provider.id) 311 | await oauth.refreshToken(provider: provider) 312 | #expect(oauth.state == .error(provider, .badResponse)) 313 | oauth.clear() 314 | 315 | // Client Credentials 316 | await oauth.requestClientCredentials(provider: provider) 317 | #expect(oauth.state == .error(provider, .badResponse)) 318 | 319 | // Device Code 320 | await oauth.requestDeviceCode(provider: provider) 321 | #expect(oauth.state == .error(provider, .badResponse)) 322 | 323 | // Device Code Polling 324 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: .secureRandom(), verificationUri: "https://example.com", expiresIn: 200, interval: 0) 325 | await oauth.poll(provider: provider, deviceCode: deviceCode) 326 | #expect(oauth.state == .error(provider, .badResponse)) 327 | } 328 | 329 | /// Tests the PKCE request parameters. 330 | @Test("Building PKCE Request") 331 | func whenBuildingPKCERequest() async throws { 332 | let pkce: OAuth.PKCE = .init() 333 | let grantType: OAuth.GrantType = .pkce(pkce) 334 | let request = OAuth.Request.auth(provider: provider, grantType: grantType) 335 | #expect(request != nil) 336 | #expect(request!.url!.absoluteString.contains("client_id=")) 337 | #expect(request!.url!.absoluteString.contains("redirect_uri=\(provider.redirectURI!)")) 338 | #expect(request!.url!.absoluteString.contains("response_type=code")) 339 | #expect(request!.url!.absoluteString.contains("state=\(pkce.state)")) 340 | #expect(request!.url!.absoluteString.contains("code_challenge=\(pkce.codeChallenge)")) 341 | #expect(request!.url!.absoluteString.contains("code_challenge_method=\(pkce.codeChallengeMethod)")) 342 | } 343 | 344 | /// Tests OAuth Secure Random State Generation 345 | @Test("OAuth Polling for Device Code Authorization") 346 | private func whenPollingForDeviceCodeAuthorization() async throws { 347 | let deviceCode: OAuth.DeviceCode = .init(deviceCode: .secureRandom(), userCode: .secureRandom(), verificationUri: "https://example.com", expiresIn: 200, interval: 0) 348 | await oauth.poll(provider: provider, deviceCode: deviceCode) 349 | let result = await waitForAuthorization(oauth) 350 | #expect(result == true) 351 | } 352 | 353 | /// Tests to make sure the PKCE code verifier and challenge are correct. 354 | /// See: https://www.oauth.com/playground/authorization-code-with-pkce.html 355 | @Test("Generating PKCE Code Challenge") 356 | func whenGeneratingPKCECodeChallenge() async throws { 357 | let codeVerifier = "irYm7d4my6egZ-ea5jFnL9XM3CYshCdcbL3OlW0w7HMvcE5d" 358 | let codeChallenge = codeVerifier.sha256.base64URL 359 | let expectedResult = "W7BYCsNLCgzw-Kf5IZFjhwd-WdPZEhTNNJGQVgOq560" 360 | #expect(codeChallenge == expectedResult) 361 | } 362 | 363 | /// Tests to make sure the grant type raw values are frozen and haven't been changed during development. 364 | @Test("Grant Type Raw Value Checking") 365 | func whenCheckingGrantType() async throws { 366 | var grantType: OAuth.GrantType = .authorizationCode(.empty) 367 | #expect(grantType.rawValue == "authorization_code") 368 | grantType = .clientCredentials 369 | #expect(grantType.rawValue == "client_credentials") 370 | grantType = .deviceCode 371 | #expect(grantType.rawValue == "device_code") 372 | grantType = .pkce(.init()) 373 | #expect(grantType.rawValue == "pkce") 374 | grantType = .refreshToken 375 | #expect(grantType.rawValue == "refresh_token") 376 | } 377 | 378 | /// Tests the adding of the Authorization header to an URLRequest. 379 | @Test("Adding Authorization Header to URLRequest") 380 | func whenAddingAuthHeader() async throws { 381 | let string = "https://github.com/codefiesta/OAuthKit" 382 | let url = URL(string: string) 383 | var urlRequest = URLRequest(url: url!) 384 | 385 | let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: nil, expiresIn: 3600, scope: nil, type: "Bearer") 386 | let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) 387 | #expect(auth.expiration != nil) 388 | #expect(auth.isExpired == false) 389 | urlRequest.addAuthorization(auth: auth) 390 | 391 | let header = urlRequest.value(forHTTPHeaderField: "Authorization") 392 | #expect(header != nil) 393 | #expect(header == "\(token.type) \(token.accessToken)") 394 | } 395 | 396 | /// Tests OAuth State changes 397 | @Test("OAuth State Changes") 398 | func whenOAuthState() async throws { 399 | let state: String = .secureRandom(count: 16) 400 | 401 | // Authorization Code 402 | var grantType: OAuth.GrantType = .authorizationCode(state) 403 | oauth.authorize(provider: provider, grantType: grantType) 404 | #expect(oauth.state == .authorizing(provider, grantType)) 405 | 406 | // PKCE 407 | let pkce: OAuth.PKCE = .init() 408 | grantType = .pkce(pkce) 409 | oauth.authorize(provider: provider, grantType: grantType) 410 | #expect(oauth.state == .authorizing(provider, grantType)) 411 | 412 | // Empty 413 | oauth.clear() 414 | #expect(oauth.state == .empty) 415 | } 416 | 417 | /// Tests OAuth Secure Random State Generation 418 | @Test("OAuth Secure Random State") 419 | func whenGeneratingOAuthSecureRandomState() async throws { 420 | let random = OAuth.secureRandom() 421 | #expect(random.count >= 43) 422 | } 423 | 424 | /// Streams the oauth status until we receive an authorization. 425 | /// This should only be used on test methods that expect an authorization to be inserted into the keychain. 426 | private func waitForAuthorization(_ oauth: OAuth) async -> Bool { 427 | let monitor: OAuth.Monitor = .init(oauth: oauth) 428 | for await state in monitor.stream { 429 | switch state { 430 | case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: 431 | break 432 | case .authorized(_, _): 433 | oauth.clear() 434 | return true 435 | } 436 | } 437 | return false 438 | } 439 | } 440 | --------------------------------------------------------------------------------