├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .spi.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── OAuthenticator │ ├── ASWebAuthenticationSession+Utility.swift │ ├── Authenticator.swift │ ├── CredentialWindowProvider.swift │ ├── DPoPKey.swift │ ├── DPoPSigner.swift │ ├── Data+Base64URLEncode.swift │ ├── Models.swift │ ├── PKCE.swift │ ├── PrivacyInfo.xcprivacy │ ├── Services │ ├── Bluesky.swift │ ├── GitHub.swift │ ├── GoogleAPI.swift │ └── Mastodon.swift │ ├── URL+QueryParams.swift │ ├── URLSession+ResponseProvider.swift │ ├── WebAuthenticationSession+Utility.swift │ └── WellknownEndpoints.swift └── Tests └── OAuthenticatorTests ├── AuthenticatorTests.swift ├── DPoPSignerTests.swift ├── GitHubTests.swift ├── GoogleTests.swift ├── MastodonTests.swift └── PKCETests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattmassicotte] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'README.md' 9 | - 'CODE_OF_CONDUCT.md' 10 | - '.editorconfig' 11 | - '.spi.yml' 12 | pull_request: 13 | branches: 14 | - main 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | name: Test 23 | timeout-minutes: 30 24 | runs-on: macOS-15 25 | env: 26 | DEVELOPER_DIR: /Applications/Xcode_16.2.app 27 | strategy: 28 | matrix: 29 | destination: 30 | - "platform=macOS" 31 | - "platform=macOS,variant=Mac Catalyst" 32 | - "platform=iOS Simulator,name=iPhone 16" 33 | - "platform=tvOS Simulator,name=Apple TV" 34 | - "platform=watchOS Simulator,name=Apple Watch Series 10 (42mm)" 35 | - "platform=visionOS Simulator,name=Apple Vision Pro" 36 | steps: 37 | - uses: actions/checkout@v4 38 | - name: Test platform ${{ matrix.destination }} 39 | run: set -o pipefail && xcodebuild -scheme OAuthenticator -destination "${{ matrix.destination }}" test | xcbeautify 40 | 41 | linux_test: 42 | name: Test Linux 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 30 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | - name: Swiftly 49 | uses: vapor/swiftly-action@v0.2.0 50 | with: 51 | toolchain: 6.0.3 52 | - name: Test 53 | run: swift test 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [OAuthenticator] 5 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | support@chimehq.com. 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Matt Massicotte 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "OAuthenticator", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .macCatalyst(.v13), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v7), 13 | .visionOS(.v1), 14 | ], 15 | products: [ 16 | .library(name: "OAuthenticator", targets: ["OAuthenticator"]), 17 | ], 18 | dependencies: [ 19 | ], 20 | targets: [ 21 | .target( 22 | name: "OAuthenticator", 23 | dependencies: [], 24 | resources: [.process("PrivacyInfo.xcprivacy")] 25 | ), 26 | .testTarget(name: "OAuthenticatorTests", dependencies: ["OAuthenticator"]), 27 | ] 28 | ) 29 | 30 | let swiftSettings: [SwiftSetting] = [ 31 | .enableExperimentalFeature("StrictConcurrency"), 32 | .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), 33 | ] 34 | 35 | for target in package.targets { 36 | var settings = target.swiftSettings ?? [] 37 | settings.append(contentsOf: swiftSettings) 38 | target.swiftSettings = settings 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status][build status badge]][build status] 2 | [![Platforms][platforms badge]][platforms] 3 | [![Documentation][documentation badge]][documentation] 4 | 5 | # OAuthenticator 6 | Lightweight OAuth 2.1 request authentication in Swift 7 | 8 | There are lots of OAuth solutions out there. This one is small, uses Swift concurrency, and offers lots of control over the process. 9 | 10 | Features: 11 | 12 | - Swift concurrency support 13 | - Fine-grained control over the entire token and refresh flow 14 | - Optional integration with `ASWebAuthenticationSession` 15 | - Control over when and if users are prompted to log into a service 16 | - Preliminary support for PAR, PKCE, Server/Client Metadata, and DPoP 17 | 18 | This library currently doesn't have functional JWT or JWK generation, and both are required for DPoP. You must use an external JWT library to do this, connected to the system via the `DPoPSigner.JWTGenerator` function. I have used [jose-swift](https://github.com/beatt83/jose-swift) with success. 19 | 20 | There's also built-in support for services to streamline integration: 21 | 22 | - GitHub 23 | - Mastodon 24 | - Google API 25 | - Bluesky 26 | 27 | If you'd like to contribute a similar thing for another service, please open a PR! 28 | 29 | ## Integration 30 | 31 | Swift Package Manager: 32 | 33 | ```swift 34 | dependencies: [ 35 | .package(url: "https://github.com/ChimeHQ/OAuthenticator", from: "0.3.0") 36 | ] 37 | ``` 38 | 39 | ## Usage 40 | 41 | The main type is the `Authenticator`. It can execute a `URLRequest` in a similar fashion to `URLSession`, but will handle all authentication requirements and tack on the needed `Authorization` header. Its behavior is controlled via `Authenticator.Configuration` and `URLResponseProvider`. By default, the `URLResponseProvider` will be a private `URLSession`, but you can customize this if needed. 42 | 43 | Setting up a `Configuration` can be more work, depending on the OAuth service you're interacting with. 44 | 45 | ```swift 46 | // backing storage for your authentication data. Without this, tokens will be tied to the lifetime of the `Authenticator`. 47 | let storage = LoginStorage { 48 | // get login here 49 | } storeLogin: { login in 50 | // store `login` for later retrieval 51 | } 52 | 53 | // application credentials for your OAuth service 54 | let appCreds = AppCredentials( 55 | clientId: "client_id", 56 | clientPassword: "client_secret", 57 | scopes: [], 58 | callbackURL: URL(string: "my://callback")! 59 | ) 60 | 61 | // the user authentication function 62 | let userAuthenticator = ASWebAuthenticationSession.userAuthenticator 63 | 64 | // functions that define how tokens are issued and refreshed 65 | // This is the most complex bit, as all the pieces depend on exactly how the OAuth-based service works. 66 | // parConfiguration, and dpopJWTGenerator are optional 67 | let tokenHandling = TokenHandling( 68 | parConfiguration: PARConfiguration(url: parEndpointURL, parameters: extraQueryParams), 69 | authorizationURLProvider: { params in URL(string: "based on app credentials") } 70 | loginProvider: { params in ... } 71 | refreshProvider: { existingLogin, appCreds, urlLoader in ... }, 72 | responseStatusProvider: TokenHandling.refreshOrAuthorizeWhenUnauthorized, 73 | dpopJWTGenerator: { params in "signed JWT" }, 74 | pkce: PKCEVerifier(hash: "S256", hasher: { ... }) 75 | ) 76 | 77 | let config = Authenticator.Configuration( 78 | appCredentials: appCreds, 79 | loginStorage: storage, 80 | tokenHandling: tokenHandling, 81 | userAuthenticator: userAuthenticator 82 | ) 83 | 84 | let authenticator = Authenticator(config: config) 85 | 86 | let myRequest = URLRequest(...) 87 | 88 | let (data, response) = try await authenticator.response(for: myRequest) 89 | ``` 90 | 91 | If you want to receive the result of the authentication process without issuing a request first, you can specify 92 | an optional `Authenticator.AuthenticationStatusHandler` callback function within the `Authenticator.Configuration` initializer. 93 | 94 | This allows you to support special cases where you need to capture the `Login` object before executing your first 95 | authenticated `URLRequest` and manage that separately. 96 | 97 | ``` swift 98 | let authenticationStatusHandler: Authenticator.AuthenticationStatusHandler = { result in 99 | switch result { 100 | case .success (let login): 101 | authenticatedLogin = login 102 | case .failure(let error): 103 | print("Authentication failed: \(error)") 104 | } 105 | } 106 | 107 | // Configure Authenticator with result callback 108 | let config = Authenticator.Configuration( 109 | appCredentials: appCreds, 110 | tokenHandling: tokenHandling, 111 | mode: .manualOnly, 112 | userAuthenticator: userAuthenticator, 113 | authenticationStatusHandler: authenticationStatusHandler 114 | ) 115 | 116 | let auth = Authenticator(config: config, urlLoader: mockLoader) 117 | try await auth.authenticate() 118 | if let authenticatedLogin = authenticatedLogin { 119 | // Process special case 120 | ... 121 | } 122 | ``` 123 | 124 | ### DPoP 125 | 126 | Constructing and signing the JSON Web Token / JSON Web Keys necessary for DPoP suppot is mostly out of the scope of this library. But here's an example of how to do it, using [Jot](https://github.com/mattmassicotte/Jot), a really basic JWT/JWK library I put together. You should be able to use this as a guide if you want to use a different JWT/JWK library. 127 | 128 | ```swift 129 | import Jot 130 | import OAuthenticator 131 | 132 | // generate a DPoP key 133 | let key = DPoPKey.P256() 134 | 135 | // define your claims, making sure to pay attention to the JSON coding keys 136 | struct DPoPTokenClaims : JSONWebTokenPayload { 137 | // standard claims 138 | let iss: String? 139 | let jti: String? 140 | let iat: Date? 141 | let exp: Date? 142 | 143 | // custom claims, which could vary depending on the service you are working with 144 | let htm: String? 145 | let htu: String? 146 | } 147 | 148 | // produce a DPoPSigner.JWTGenerator function from that key 149 | extension DPoPSigner { 150 | static func JSONWebTokenGenerator(dpopKey: DPoPKey) -> DPoPSigner.JWTGenerator { 151 | let id = dpopKey.id.uuidString 152 | 153 | return { params in 154 | // construct the private key 155 | let key = try dpopKey.p256PrivateKey 156 | 157 | // make the JWK 158 | let jwk = JSONWebKey(p256Key: key.publicKey) 159 | 160 | // fill in all the JWT fields, including whatever custom claims you need 161 | let newToken = JSONWebToken( 162 | header: JSONWebTokenHeader( 163 | algorithm: .ES256, 164 | type: params.keyType, 165 | keyId: id, 166 | jwk: jwk 167 | ), 168 | payload: DPoPTokenClaims( 169 | iss: params.issuingServer, 170 | htm: params.httpMethod, 171 | htu: params.requestEndpoint 172 | ) 173 | ) 174 | 175 | return try newToken.encode(with: key) 176 | } 177 | } 178 | } 179 | ``` 180 | 181 | ### GitHub 182 | 183 | OAuthenticator also comes with pre-packaged configuration for GitHub, which makes set up much more straight-forward. 184 | 185 | ```swift 186 | // pre-configured for GitHub 187 | let appCreds = AppCredentials(clientId: "client_id", 188 | clientPassword: "client_secret", 189 | scopes: [], 190 | callbackURL: URL(string: "my://callback")!) 191 | 192 | let config = Authenticator.Configuration(appCredentials: appCreds, 193 | tokenHandling: GitHub.tokenHandling()) 194 | 195 | let authenticator = Authenticator(config: config) 196 | 197 | let myRequest = URLRequest(...) 198 | 199 | let (data, response) = try await authenticator.response(for: myRequest) 200 | ``` 201 | 202 | 203 | ### Mastodon 204 | 205 | OAuthenticator also comes with pre-packaged configuration for Mastodon, which makes set up much more straight-forward. 206 | For more info, please check out [https://docs.joinmastodon.org/client/token/](https://docs.joinmastodon.org/client/token/) 207 | 208 | ```swift 209 | // pre-configured for Mastodon 210 | let userTokenParameters = Mastodon.UserTokenParameters( 211 | host: "mastodon.social", 212 | clientName: "MyMastodonApp", 213 | redirectURI: "myMastodonApp://mastodon/oauth", 214 | scopes: ["read", "write", "follow"] 215 | ) 216 | 217 | // The first thing we will need to do is to register an application, in order to be able to generate access tokens later. 218 | // These values will be used to generate access tokens, so they should be cached for later use 219 | let registrationData = try await Mastodon.register(with: userTokenParameters) { request in 220 | try await URLSession.shared.data(for: request) 221 | } 222 | 223 | // Now that we have an application, let’s obtain an access token that will authenticate our requests as that client application. 224 | guard let redirectURI = registrationData.redirectURI, let callbackURL = URL(string: redirectURI) else { 225 | throw AuthenticatorError.missingRedirectURI 226 | } 227 | 228 | let appCreds = AppCredentials( 229 | clientId: registrationData.clientID, 230 | clientPassword: registrationData.clientSecret, 231 | scopes: userTokenParameters.scopes, 232 | callbackURL: callbackURL 233 | ) 234 | 235 | let config = Authenticator.Configuration( 236 | appCredentials: appCreds, 237 | tokenHandling: Mastodon.tokenHandling(with: userTokenParameters) 238 | ) 239 | 240 | let authenticator = Authenticator(config: config) 241 | 242 | var urlBuilder = URLComponents() 243 | urlBuilder.scheme = Mastodon.scheme 244 | urlBuilder.host = userTokenParameters.host 245 | 246 | guard let url = urlBuilder.url else { 247 | throw AuthenticatorError.missingScheme 248 | } 249 | 250 | let request = URLRequest(url: url) 251 | 252 | let (data, response) = try await authenticator.response(for: request) 253 | ``` 254 | 255 | ### Google API 256 | OAuthenticator also comes with pre-packaged configuration for Google APIs (access to Google Drive, Google People, Google Calendar, ...) according to the application requested scopes. 257 | 258 | More info about those at [Google Workspace](https://developers.google.com/workspace). The Google OAuth process is described in [Google Identity](https://developers.google.com/identity) 259 | 260 | Integration example below: 261 | ```swift 262 | // Configuration for Google API 263 | 264 | // Define how to store and retrieve the Google Access and Refresh Token 265 | let storage = LoginStorage { 266 | // Fetch token and return them as a Login object 267 | return LoginFromSecureStorage(...) 268 | } storeLogin: { login in 269 | // Store access and refresh token in Secure storage 270 | MySecureStorage(login: login) 271 | } 272 | 273 | let appCreds = AppCredentials(clientId: googleClientApp.client_id, 274 | clientPassword: googleClientApp.client_secret, 275 | scopes: googleClientApp.scopes, 276 | callbackURL: googleClient.callbackURL) 277 | 278 | let config = Authenticator.Configuration(appCredentials: Self.oceanCredentials, 279 | loginStorage: storage, 280 | tokenHandling: tokenHandling, 281 | mode: .automatic) 282 | 283 | let authenticator = Authenticator(config: config) 284 | 285 | // If you just want the user to authenticate his account and get the tokens, do 1: 286 | // If you want to access a secure Google endpoint with the proper access token, do 2: 287 | 288 | // 1: Only Authenticate 289 | try await authenticator.authenticate() 290 | 291 | // 2: Access secure Google endpoint (ie: Google Drive: upload a file) with access token 292 | var urlBuilder = URLComponents() 293 | urlBuilder.scheme = GoogleAPI.scheme // https: 294 | urlBuilder.host = GoogleAPI.host // www.googleapis.com 295 | urlBuilder.path = GoogleAPI.path // /upload/drive/v3/files 296 | urlBuilder.queryItems = [ 297 | URLQueryItem(name: GoogleDrive.uploadType, value: "media"), 298 | ] 299 | 300 | guard let url = urlBuilder.url else { 301 | throw AuthenticatorError.missingScheme 302 | } 303 | 304 | let request = URLRequest(url: url) 305 | request.httpMethod = "POST" 306 | request.httpBody = ... // File data to upload 307 | 308 | let (data, response) = try await authenticator.response(for: request) 309 | ``` 310 | 311 | ### Bluesky API 312 | 313 | Bluesky has a [complex](https://docs.bsky.app/docs/advanced-guides/oauth-client) OAuth implementation. 314 | 315 | > [!WARNING] 316 | > bsky.social's DPoP nonce changes frequently (maybe every 10-30 seconds?). I have observed that if the nonce changes between when a user requested a 2FA code and the code being entered, the server will reject the login attempt. Trying again will involve user interaction. 317 | 318 | Resovling PDS servers for a user is involved and beyond the scope of this library. However, [ATResolve](https://github.com/mattmassicotte/ATResolve) might help! 319 | 320 | If you are using a platform that does not have [CryptoKit](https://developer.apple.com/documentation/cryptokit/) available, like Linux, you'll have to supply a `PKCEVerifier` parameter to the `Bluesky.tokenHandling` function. 321 | 322 | See above for an example of how to implement DPoP JWTs. 323 | 324 | ```swift 325 | let responseProvider = URLSession.defaultProvider 326 | let account = "myhandle.com" 327 | let server = "https://bsky.social" 328 | let clientMetadataEndpoint = "https://example.com/public/facing/client-metadata.json" 329 | 330 | // You should know the client configuration, and could generate the needed AppCredentials struct manually instead. 331 | // The required fields are "clientId", "callbackURL", and "scopes" 332 | let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) 333 | let serverConfig = try await ServerMetadata.load(for: server, provider: provider) 334 | 335 | let jwtGenerator: DPoPSigner.JWTGenerator = { params in 336 | // generate a P-256 signed token that uses `params` to match the specifications from 337 | // https://docs.bsky.app/docs/advanced-guides/oauth-client#dpop 338 | } 339 | 340 | let tokenHandling = Bluesky.tokenHandling( 341 | account: account, 342 | server: serverConfig, 343 | client: clientConfig, 344 | jwtGenerator: jwtGenerator 345 | ) 346 | 347 | let config = Authenticator.Configuration( 348 | appCredentials: clientConfig.credentials, 349 | loginStorage: loginStore, 350 | tokenHandling: tokenHandling 351 | ) 352 | 353 | let authenticator = Authenticator(config: config) 354 | 355 | // you can now use this authenticator to make requests against the user's PDS. Remember, the PDS will not be the same as the authentication server. 356 | ``` 357 | 358 | ## Contributing and Collaboration 359 | 360 | I'd love to hear from you! Get in touch via an issue or pull request. 361 | 362 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 363 | 364 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 365 | 366 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 367 | 368 | [build status]: https://github.com/ChimeHQ/OAuthenticator/actions 369 | [build status badge]: https://github.com/ChimeHQ/OAuthenticator/workflows/CI/badge.svg 370 | [platforms]: https://swiftpackageindex.com/ChimeHQ/OAuthenticator 371 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FOAuthenticator%2Fbadge%3Ftype%3Dplatforms 372 | [documentation]: https://swiftpackageindex.com/ChimeHQ/OAuthenticator/main/documentation 373 | [documentation badge]: https://img.shields.io/badge/Documentation-DocC-blue 374 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/ASWebAuthenticationSession+Utility.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(AuthenticationServices) 3 | import AuthenticationServices 4 | 5 | enum WebAuthenticationSessionError: Error { 6 | case resultInvalid 7 | } 8 | 9 | @available(tvOS 16.0, macCatalyst 13.0, *) 10 | extension ASWebAuthenticationSession { 11 | @MainActor 12 | convenience init(url: URL, callbackURLScheme: String, completionHandler: @escaping (Result) -> Void) { 13 | self.init(url: url, callbackURLScheme: callbackURLScheme, completionHandler: { (resultURL, error) in 14 | switch (resultURL, error) { 15 | case (_, let error?): 16 | completionHandler(.failure(error)) 17 | case (let callbackURL?, nil): 18 | completionHandler(.success(callbackURL)) 19 | default: 20 | completionHandler(.failure(WebAuthenticationSessionError.resultInvalid)) 21 | } 22 | }) 23 | } 24 | } 25 | 26 | 27 | @available(tvOS 16.0, macCatalyst 13.0, *) 28 | extension ASWebAuthenticationSession { 29 | #if os(iOS) || os(macOS) 30 | @MainActor 31 | public static func begin(with url: URL, callbackURLScheme scheme: String, contextProvider: ASWebAuthenticationPresentationContextProviding) async throws -> URL { 32 | try await withCheckedThrowingContinuation { continuation in 33 | let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme, completionHandler: { result in 34 | continuation.resume(with: result) 35 | }) 36 | 37 | if #available(macCatalyst 13.1, *) { 38 | session.prefersEphemeralWebBrowserSession = true 39 | } 40 | 41 | session.presentationContextProvider = contextProvider 42 | 43 | session.start() 44 | } 45 | } 46 | 47 | @MainActor 48 | public static func begin(with url: URL, callbackURLScheme scheme: String) async throws -> URL { 49 | try await begin(with: url, callbackURLScheme: scheme, contextProvider: CredentialWindowProvider()) 50 | } 51 | 52 | @MainActor 53 | public static func userAuthenticator(url: URL, scheme: String) async throws -> URL { 54 | try await begin(with: url, callbackURLScheme: scheme) 55 | } 56 | #else 57 | @MainActor 58 | public static func userAuthenticator(url: URL, scheme: String) async throws -> URL { 59 | try await withCheckedThrowingContinuation { continuation in 60 | let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme, completionHandler: { result in 61 | continuation.resume(with: result) 62 | }) 63 | 64 | #if os(watchOS) 65 | session.prefersEphemeralWebBrowserSession = true 66 | #endif 67 | 68 | session.start() 69 | } 70 | } 71 | #endif 72 | } 73 | #endif 74 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Authenticator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | #if canImport(AuthenticationServices) 6 | import AuthenticationServices 7 | #endif 8 | 9 | public enum AuthenticatorError: Error, Hashable { 10 | case missingScheme 11 | case missingAuthorizationCode 12 | case missingTokenURL 13 | case missingAuthorizationURL 14 | case refreshUnsupported 15 | case refreshNotPossible 16 | case tokenInvalid 17 | case manualAuthenticationRequired 18 | case httpResponseExpected 19 | case unauthorizedRefreshFailed 20 | case missingRedirectURI 21 | case missingRefreshToken 22 | case missingScope 23 | case failingAuthenticatorUsed 24 | case dpopTokenExpected(String) 25 | case parRequestURIMissing 26 | case stateTokenMismatch(String, String) 27 | case pkceRequired 28 | } 29 | 30 | /// Manage state required to executed authenticated URLRequests. 31 | public actor Authenticator { 32 | public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL 33 | public typealias AuthenticationStatusHandler = (Result) -> Void 34 | 35 | /// A `UserAuthenticator` that always fails. Useful as a placeholder 36 | /// for testing and for doing manual authentication with an external 37 | /// instance not available at configuration-creation time. 38 | @Sendable 39 | public static func failingUserAuthenticator(_ url: URL, _ user: String) throws -> URL { 40 | throw AuthenticatorError.failingAuthenticatorUsed 41 | } 42 | 43 | public enum UserAuthenticationMode: Hashable, Sendable { 44 | /// User authentication will be triggered on-demand. 45 | case automatic 46 | 47 | /// User authentication will only occur via an explicit call to `authenticate()`. 48 | /// 49 | /// This is handy for controlling when users are prompted. It also makes it possible to handle situations where kicking a user out to the web is impossible. 50 | case manualOnly 51 | } 52 | 53 | struct PARResponse: Codable, Hashable, Sendable { 54 | public let requestURI: String 55 | public let expiresIn: Int 56 | 57 | enum CodingKeys: String, CodingKey { 58 | case requestURI = "request_uri" 59 | case expiresIn = "expires_in" 60 | } 61 | 62 | var expiry: Date { 63 | Date(timeIntervalSinceNow: Double(expiresIn)) 64 | } 65 | } 66 | 67 | public struct Configuration { 68 | public let appCredentials: AppCredentials 69 | 70 | public let loginStorage: LoginStorage? 71 | public let tokenHandling: TokenHandling 72 | public let userAuthenticator: UserAuthenticator 73 | public let mode: UserAuthenticationMode 74 | 75 | // Specify an authenticationResult closure to obtain result and grantedScope 76 | public let authenticationStatusHandler: AuthenticationStatusHandler? 77 | 78 | #if canImport(AuthenticationServices) 79 | @available(tvOS 16.0, macCatalyst 13.0, *) 80 | public init( 81 | appCredentials: AppCredentials, 82 | loginStorage: LoginStorage? = nil, 83 | tokenHandling: TokenHandling, 84 | mode: UserAuthenticationMode = .automatic, 85 | authenticationStatusHandler: AuthenticationStatusHandler? = nil 86 | ) { 87 | self.appCredentials = appCredentials 88 | self.loginStorage = loginStorage 89 | self.tokenHandling = tokenHandling 90 | self.mode = mode 91 | 92 | // It *should* be possible to use just a reference to 93 | // ASWebAuthenticationSession.userAuthenticator directly here 94 | // with GlobalActorIsolatedTypesUsability, but it isn't working 95 | self.userAuthenticator = { try await ASWebAuthenticationSession.userAuthenticator(url: $0, scheme: $1) } 96 | self.authenticationStatusHandler = authenticationStatusHandler 97 | } 98 | #endif 99 | 100 | public init( 101 | appCredentials: AppCredentials, 102 | loginStorage: LoginStorage? = nil, 103 | tokenHandling: TokenHandling, 104 | mode: UserAuthenticationMode = .automatic, 105 | userAuthenticator: @escaping UserAuthenticator, 106 | authenticationStatusHandler: AuthenticationStatusHandler? = nil 107 | ) { 108 | self.appCredentials = appCredentials 109 | self.loginStorage = loginStorage 110 | self.tokenHandling = tokenHandling 111 | self.mode = mode 112 | self.userAuthenticator = userAuthenticator 113 | self.authenticationStatusHandler = authenticationStatusHandler 114 | } 115 | } 116 | 117 | let config: Configuration 118 | 119 | let urlLoader: URLResponseProvider 120 | private var activeTokenTask: Task? 121 | private var localLogin: Login? 122 | private var dpop = DPoPSigner() 123 | private let stateToken = UUID().uuidString 124 | 125 | public init(config: Configuration, urlLoader loader: URLResponseProvider? = nil) { 126 | self.config = config 127 | 128 | self.urlLoader = loader ?? URLSession.defaultProvider 129 | } 130 | 131 | /// A default `URLSession`-backed `URLResponseProvider`. 132 | @available(*, deprecated, message: "Please move to URLSession.defaultProvider") 133 | @MainActor 134 | public static let defaultResponseProvider: URLResponseProvider = { 135 | let session = URLSession(configuration: .default) 136 | 137 | return session.responseProvider 138 | }() 139 | 140 | /// Add authentication for `request`, execute it, and return its result. 141 | public func response(for request: URLRequest) async throws -> (Data, URLResponse) { 142 | let userAuthenticator = config.userAuthenticator 143 | 144 | let login = try await loginTaskResult(manual: false, userAuthenticator: userAuthenticator) 145 | 146 | let result = try await authedResponse(for: request, login: login) 147 | 148 | let action = try config.tokenHandling.responseStatusProvider(result) 149 | 150 | switch action { 151 | case .authorize: 152 | let newLogin = try await loginFromTask(task: Task { 153 | return try await performUserAuthentication(manual: false, userAuthenticator: userAuthenticator) 154 | }) 155 | 156 | return try await authedResponse(for: request, login: newLogin) 157 | case .refresh: 158 | let newLogin = try await loginFromTask(task: Task { 159 | guard let value = try await refresh(with: login) else { 160 | throw AuthenticatorError.unauthorizedRefreshFailed 161 | } 162 | 163 | return value 164 | }) 165 | 166 | return try await authedResponse(for: request, login: newLogin) 167 | case .refreshOrAuthorize: 168 | let newLogin = try await loginFromTask(task: Task { 169 | if let value = try await refresh(with: login) { 170 | return value 171 | } 172 | 173 | return try await performUserAuthentication(manual: false, userAuthenticator: userAuthenticator) 174 | }) 175 | 176 | return try await authedResponse(for: request, login: newLogin) 177 | case .valid: 178 | return result 179 | } 180 | } 181 | 182 | private func authedResponse(for request: URLRequest, login: Login) async throws -> (Data, URLResponse) { 183 | var authedRequest = request 184 | let token = login.accessToken.value 185 | 186 | if config.tokenHandling.dpopJWTGenerator == nil { 187 | authedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") 188 | } 189 | 190 | return try await dpopResponse(for: authedRequest, login: login) 191 | } 192 | 193 | /// Manually perform user authentication, if required. 194 | public func authenticate(with userAuthenticator: UserAuthenticator? = nil) async throws { 195 | let _ = try await loginTaskResult(manual: true, userAuthenticator: userAuthenticator ?? config.userAuthenticator) 196 | } 197 | } 198 | 199 | extension Authenticator { 200 | private func retrieveLogin() async throws -> Login? { 201 | guard let storage = config.loginStorage else { 202 | return localLogin 203 | } 204 | 205 | return try await storage.retrieveLogin() 206 | } 207 | 208 | private func storeLogin(_ login: Login) async throws { 209 | guard let storage = config.loginStorage else { 210 | self.localLogin = login 211 | return 212 | } 213 | 214 | try await storage.storeLogin(login) 215 | } 216 | 217 | private func clearLogin() async { 218 | guard let storage = config.loginStorage else { return } 219 | 220 | let invalidLogin = Login(token: "invalid", validUntilDate: .distantPast) 221 | 222 | do { 223 | try await storage.storeLogin(invalidLogin) 224 | } catch { 225 | print("failed to store an invalid login, possibly stuck", error) 226 | } 227 | } 228 | } 229 | 230 | extension Authenticator { 231 | private func makeLoginTask(manual: Bool, userAuthenticator: @escaping UserAuthenticator) -> Task { 232 | return Task { 233 | guard let login = try await retrieveLogin() else { 234 | return try await performUserAuthentication(manual: manual, userAuthenticator: userAuthenticator) 235 | } 236 | 237 | if login.accessToken.valid { 238 | return login 239 | } 240 | 241 | if let refreshedLogin = try await refresh(with: login) { 242 | return refreshedLogin 243 | } 244 | 245 | return try await performUserAuthentication(manual: manual, userAuthenticator: userAuthenticator) 246 | } 247 | } 248 | 249 | private func loginTaskResult(manual: Bool, userAuthenticator: @escaping UserAuthenticator) async throws -> Login { 250 | let task = activeTokenTask ?? makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) 251 | 252 | var login: Login 253 | do { 254 | do { 255 | login = try await loginFromTask(task: task) 256 | } catch AuthenticatorError.tokenInvalid { 257 | let newTask = makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) 258 | login = try await loginFromTask(task: newTask) 259 | } 260 | 261 | // Inform authenticationResult closure of new login information 262 | self.config.authenticationStatusHandler?(.success(login)) 263 | } 264 | catch let authenticatorError as AuthenticatorError { 265 | self.config.authenticationStatusHandler?(.failure(authenticatorError)) 266 | 267 | // Rethrow error 268 | throw authenticatorError 269 | } 270 | 271 | return login 272 | } 273 | 274 | private func loginFromTask(task: Task) async throws -> Login { 275 | self.activeTokenTask = task 276 | 277 | let login: Login 278 | 279 | do { 280 | login = try await task.value 281 | } catch { 282 | // clear this value on error, but only if has not changed 283 | if task == self.activeTokenTask { 284 | self.activeTokenTask = nil 285 | } 286 | 287 | throw error 288 | } 289 | 290 | guard login.accessToken.valid else { 291 | throw AuthenticatorError.tokenInvalid 292 | } 293 | 294 | return login 295 | } 296 | 297 | private func performUserAuthentication(manual: Bool, userAuthenticator: UserAuthenticator) async throws -> Login { 298 | if manual == false && config.mode == .manualOnly { 299 | throw AuthenticatorError.manualAuthenticationRequired 300 | } 301 | 302 | let parRequestURI = try await getPARRequestURI() 303 | 304 | let authConfig = TokenHandling.AuthorizationURLParameters( 305 | credentials: config.appCredentials, 306 | pcke: config.tokenHandling.pkce, 307 | parRequestURI: parRequestURI, 308 | stateToken: stateToken, 309 | responseProvider: { try await self.dpopResponse(for: $0, login: nil) } 310 | ) 311 | 312 | let tokenURL = try await config.tokenHandling.authorizationURLProvider(authConfig) 313 | 314 | let scheme = try config.appCredentials.callbackURLScheme 315 | 316 | let callbackURL = try await userAuthenticator(tokenURL, scheme) 317 | 318 | let params = TokenHandling.LoginProviderParameters( 319 | authorizationURL: tokenURL, 320 | credentials: config.appCredentials, 321 | redirectURL: callbackURL, 322 | responseProvider: { try await self.dpopResponse(for: $0, login: nil) }, 323 | stateToken: stateToken, 324 | pcke: config.tokenHandling.pkce 325 | ) 326 | 327 | let login = try await config.tokenHandling.loginProvider(params) 328 | 329 | try await storeLogin(login) 330 | 331 | return login 332 | } 333 | 334 | private func refresh(with login: Login) async throws -> Login? { 335 | guard let refreshProvider = config.tokenHandling.refreshProvider else { 336 | return nil 337 | } 338 | 339 | guard let refreshToken = login.refreshToken else { 340 | return nil 341 | } 342 | 343 | guard refreshToken.valid else { 344 | return nil 345 | } 346 | 347 | do { 348 | let login = try await refreshProvider(login, config.appCredentials, { try await self.dpopResponse(for: $0, login: nil) }) 349 | 350 | try await storeLogin(login) 351 | 352 | return login 353 | } catch { 354 | await clearLogin() 355 | 356 | throw error 357 | } 358 | } 359 | 360 | private func parRequest(url: URL, params: [String: String]) async throws -> PARResponse { 361 | guard let pkce = config.tokenHandling.pkce else { 362 | throw AuthenticatorError.pkceRequired 363 | } 364 | 365 | let challenge = pkce.challenge 366 | let scopes = config.appCredentials.scopes.joined(separator: " ") 367 | let callbackURI = config.appCredentials.callbackURL 368 | let clientId = config.appCredentials.clientId 369 | 370 | var request = URLRequest(url: url) 371 | request.httpMethod = "POST" 372 | request.setValue("application/json", forHTTPHeaderField: "Accept") 373 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 374 | 375 | let base: [String: String] = [ 376 | "client_id": clientId, 377 | "state": stateToken, 378 | "scope": scopes, 379 | "response_type": "code", 380 | "redirect_uri": callbackURI.absoluteString, 381 | "code_challenge": challenge.value, 382 | "code_challenge_method": challenge.method, 383 | ] 384 | 385 | let body = params 386 | .merging(base, uniquingKeysWith: { a, b in a }) 387 | .map({ [$0, $1].joined(separator: "=") }) 388 | .joined(separator: "&") 389 | 390 | request.httpBody = Data(body.utf8) 391 | 392 | let (parData, _) = try await dpopResponse(for: request, login: nil) 393 | 394 | return try JSONDecoder().decode(PARResponse.self, from: parData) 395 | } 396 | 397 | private func getPARRequestURI() async throws -> String? { 398 | guard let parConfig = config.tokenHandling.parConfiguration else { 399 | return nil 400 | } 401 | 402 | let parResponse = try await parRequest(url: parConfig.url, params: parConfig.parameters) 403 | 404 | return parResponse.requestURI 405 | } 406 | } 407 | 408 | extension Authenticator { 409 | public nonisolated var responseProvider: URLResponseProvider { 410 | { try await self.response(for: $0) } 411 | } 412 | 413 | private func dpopResponse(for request: URLRequest, login: Login?) async throws -> (Data, URLResponse) { 414 | guard let generator = config.tokenHandling.dpopJWTGenerator else { 415 | return try await urlLoader(request) 416 | } 417 | 418 | guard let pkce = config.tokenHandling.pkce else { 419 | throw AuthenticatorError.pkceRequired 420 | } 421 | 422 | let token = login?.accessToken.value 423 | let tokenHash = token.map { pkce.hashFunction($0) } 424 | 425 | return try await self.dpop.response( 426 | isolation: self, 427 | for: request, 428 | using: generator, 429 | token: token, 430 | tokenHash: tokenHash, 431 | issuingServer: login?.issuingServer, 432 | provider: urlLoader 433 | ) 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/CredentialWindowProvider.swift: -------------------------------------------------------------------------------- 1 | #if (os(iOS) || os(macOS)) && canImport(AuthenticationServices) 2 | 3 | #if os(iOS) 4 | import UIKit 5 | #else 6 | import Cocoa 7 | #endif 8 | import AuthenticationServices 9 | 10 | @MainActor 11 | public final class CredentialWindowProvider: NSObject { 12 | #if os(iOS) 13 | private var scenes: [UIWindowScene] { 14 | UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) 15 | } 16 | 17 | private var window: UIWindow { 18 | if #available(iOS 15.0, *) { 19 | return scenes.compactMap({ $0.keyWindow }).first! 20 | } else { 21 | return scenes.flatMap({ $0.windows }).first! 22 | } 23 | } 24 | #endif 25 | } 26 | 27 | extension CredentialWindowProvider: ASWebAuthenticationPresentationContextProviding { 28 | public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 29 | #if os(iOS) 30 | return window 31 | #else 32 | return NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.orderedWindows.first! 33 | #endif 34 | } 35 | } 36 | 37 | #endif 38 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/DPoPKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A private key and ID pair used for DPoP signing. 4 | public struct DPoPKey: Codable, Hashable, Sendable { 5 | public let data: Data 6 | public let id: UUID 7 | 8 | public init(keyData: Data) { 9 | self.id = UUID() 10 | self.data = keyData 11 | } 12 | } 13 | 14 | #if canImport(CryptoKit) 15 | import CryptoKit 16 | 17 | extension DPoPKey { 18 | /// Generate a new instance with P-256 key data. 19 | public static func P256() -> DPoPKey { 20 | let data = CryptoKit.P256.Signing.PrivateKey().rawRepresentation 21 | 22 | return DPoPKey(keyData: data) 23 | } 24 | 25 | public var p256PrivateKey: CryptoKit.P256.Signing.PrivateKey { 26 | get throws { 27 | try CryptoKit.P256.Signing.PrivateKey(rawRepresentation: data) 28 | } 29 | } 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/DPoPSigner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public struct DPoPRequestPayload: Codable, Hashable, Sendable { 7 | public let uniqueCode: String 8 | public let httpMethod: String 9 | public let httpRequestURL: String 10 | /// UNIX type, seconds since epoch 11 | public let createdAt: Int 12 | /// UNIX type, seconds since epoch 13 | public let expiresAt: Int 14 | public let nonce: String? 15 | public let authorizationServerIssuer: String 16 | public let accessTokenHash: String 17 | 18 | public enum CodingKeys: String, CodingKey { 19 | case uniqueCode = "jti" 20 | case httpMethod = "htm" 21 | case httpRequestURL = "htu" 22 | case createdAt = "iat" 23 | case expiresAt = "exp" 24 | case nonce 25 | case authorizationServerIssuer = "iss" 26 | case accessTokenHash = "ath" 27 | } 28 | 29 | public init( 30 | httpMethod: String, 31 | httpRequestURL: String, 32 | createdAt: Int, 33 | expiresAt: Int, 34 | nonce: String, 35 | authorizationServerIssuer: String, 36 | accessTokenHash: String 37 | ) { 38 | self.uniqueCode = UUID().uuidString 39 | self.httpMethod = httpMethod 40 | self.httpRequestURL = httpRequestURL 41 | self.createdAt = createdAt 42 | self.expiresAt = expiresAt 43 | self.nonce = nonce 44 | self.authorizationServerIssuer = authorizationServerIssuer 45 | self.accessTokenHash = accessTokenHash 46 | } 47 | } 48 | 49 | public enum DPoPError: Error { 50 | case nonceExpected(URLResponse) 51 | case requestInvalid(URLRequest) 52 | } 53 | 54 | /// Manages state and operations for OAuth Demonstrating Proof-of-Possession (DPoP). 55 | /// 56 | /// Currently only uses ES256. 57 | /// 58 | /// Details here: https://datatracker.ietf.org/doc/html/rfc9449 59 | public final class DPoPSigner { 60 | public struct JWTParameters: Sendable, Hashable { 61 | public let keyType: String 62 | 63 | public let httpMethod: String 64 | public let requestEndpoint: String 65 | public let nonce: String? 66 | public let tokenHash: String? 67 | public let issuingServer: String? 68 | } 69 | 70 | public typealias NonceDecoder = (Data, URLResponse) throws -> String 71 | public typealias JWTGenerator = @Sendable (JWTParameters) async throws -> String 72 | private let nonceDecoder: NonceDecoder 73 | public var nonce: String? 74 | 75 | public static func nonceHeaderDecoder(data: Data, response: URLResponse) throws -> String { 76 | guard let value = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "DPoP-Nonce") else { 77 | print("data:", String(decoding: data, as: UTF8.self)) 78 | throw DPoPError.nonceExpected(response) 79 | } 80 | 81 | return value 82 | } 83 | 84 | public init(nonceDecoder: @escaping NonceDecoder = nonceHeaderDecoder) { 85 | self.nonceDecoder = nonceDecoder 86 | } 87 | } 88 | 89 | extension DPoPSigner { 90 | public func authenticateRequest( 91 | _ request: inout URLRequest, 92 | isolation: isolated (any Actor), 93 | using jwtGenerator: JWTGenerator, 94 | token: String?, 95 | tokenHash: String?, 96 | issuer: String? 97 | ) async throws { 98 | guard 99 | let method = request.httpMethod, 100 | let url = request.url 101 | else { 102 | throw DPoPError.requestInvalid(request) 103 | } 104 | 105 | let params = JWTParameters( 106 | keyType: "dpop+jwt", 107 | httpMethod: method, 108 | requestEndpoint: url.absoluteString, 109 | nonce: nonce, 110 | tokenHash: tokenHash, 111 | issuingServer: issuer 112 | ) 113 | 114 | let jwt = try await jwtGenerator(params) 115 | 116 | request.setValue(jwt, forHTTPHeaderField: "DPoP") 117 | 118 | if let token { 119 | request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") 120 | } 121 | } 122 | 123 | @discardableResult 124 | public func setNonce(from response: URLResponse) -> Bool { 125 | let newValue = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "dpop-nonce") 126 | 127 | nonce = newValue 128 | 129 | return newValue != nil 130 | } 131 | 132 | public func response( 133 | isolation: isolated (any Actor), 134 | for request: URLRequest, 135 | using jwtGenerator: JWTGenerator, 136 | token: String?, 137 | tokenHash: String?, 138 | issuingServer: String?, 139 | provider: URLResponseProvider 140 | ) async throws -> (Data, URLResponse) { 141 | var request = request 142 | 143 | try await authenticateRequest(&request, isolation: isolation, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) 144 | 145 | let (data, response) = try await provider(request) 146 | 147 | let existingNonce = nonce 148 | 149 | self.nonce = try nonceDecoder(data, response) 150 | 151 | if nonce == existingNonce { 152 | return (data, response) 153 | } 154 | 155 | print("DPoP nonce updated", existingNonce ?? "", nonce ?? "") 156 | 157 | // repeat once, using newly-established nonce 158 | try await authenticateRequest(&request, isolation: isolation, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) 159 | 160 | return try await provider(request) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Data+Base64URLEncode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | func base64EncodedURLEncodedString() -> String { 5 | base64EncodedString() 6 | .replacingOccurrences(of: "+", with: "-") 7 | .replacingOccurrences(of: "/", with: "_") 8 | .replacingOccurrences(of: "=", with: "") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Models.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// Function that can execute a `URLRequest`. 7 | /// 8 | /// This is used to abstract the actual networking system from the underlying authentication 9 | /// mechanism. 10 | public typealias URLResponseProvider = @Sendable (URLRequest) async throws -> (Data, URLResponse) 11 | 12 | /// Holds an access token value and its expiry. 13 | public struct Token: Codable, Hashable, Sendable { 14 | /// The access token. 15 | public let value: String 16 | 17 | /// An optional expiry. 18 | public let expiry: Date? 19 | 20 | public init(value: String, expiry: Date? = nil) { 21 | self.value = value 22 | self.expiry = expiry 23 | } 24 | 25 | public init(value: String, expiresIn seconds: Int) { 26 | self.value = value 27 | self.expiry = Date(timeIntervalSinceNow: TimeInterval(seconds)) 28 | } 29 | 30 | /// Determines if the token object is valid. 31 | /// 32 | /// A token without an expiry is unconditionally valid. 33 | public var valid: Bool { 34 | guard let date = expiry else { return true } 35 | 36 | return date.timeIntervalSinceNow > 0 37 | } 38 | } 39 | 40 | public struct Login: Codable, Hashable, Sendable { 41 | public var accessToken: Token 42 | public var refreshToken: Token? 43 | 44 | // User authorized scopes 45 | public var scopes: String? 46 | public var issuingServer: String? 47 | 48 | public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil, issuingServer: String? = nil) { 49 | self.accessToken = accessToken 50 | self.refreshToken = refreshToken 51 | self.scopes = scopes 52 | self.issuingServer = issuingServer 53 | } 54 | 55 | public init(token: String, validUntilDate: Date? = nil) { 56 | self.init(accessToken: Token(value: token, expiry: validUntilDate)) 57 | } 58 | } 59 | 60 | public struct AppCredentials: Codable, Hashable, Sendable { 61 | public var clientId: String 62 | public var clientPassword: String 63 | public var scopes: [String] 64 | public var callbackURL: URL 65 | 66 | public init(clientId: String, clientPassword: String, scopes: [String], callbackURL: URL) { 67 | self.clientId = clientId 68 | self.clientPassword = clientPassword 69 | self.scopes = scopes 70 | self.callbackURL = callbackURL 71 | } 72 | 73 | public var scopeString: String { 74 | return scopes.joined(separator: " ") 75 | } 76 | 77 | public var callbackURLScheme: String { 78 | get throws { 79 | guard let scheme = callbackURL.scheme else { 80 | throw AuthenticatorError.missingScheme 81 | } 82 | 83 | return scheme 84 | } 85 | } 86 | } 87 | 88 | public struct LoginStorage: Sendable { 89 | public typealias RetrieveLogin = @Sendable () async throws -> Login? 90 | public typealias StoreLogin = @Sendable (Login) async throws -> Void 91 | 92 | public let retrieveLogin: RetrieveLogin 93 | public let storeLogin: StoreLogin 94 | 95 | public init(retrieveLogin: @escaping RetrieveLogin, storeLogin: @escaping StoreLogin) { 96 | self.retrieveLogin = retrieveLogin 97 | self.storeLogin = storeLogin 98 | } 99 | } 100 | 101 | public struct PARConfiguration: Hashable, Sendable { 102 | public let url: URL 103 | public let parameters: [String: String] 104 | 105 | public init(url: URL, parameters: [String : String] = [:]) { 106 | self.url = url 107 | self.parameters = parameters 108 | } 109 | } 110 | 111 | public struct TokenHandling { 112 | public enum ResponseStatus: Hashable, Sendable { 113 | case valid 114 | case refresh 115 | case authorize 116 | case refreshOrAuthorize 117 | } 118 | 119 | public struct AuthorizationURLParameters: Sendable { 120 | public let credentials: AppCredentials 121 | public let pcke: PKCEVerifier? 122 | public let parRequestURI: String? 123 | public let stateToken: String 124 | public let responseProvider: URLResponseProvider 125 | } 126 | 127 | public struct LoginProviderParameters: Sendable { 128 | public let authorizationURL: URL 129 | public let credentials: AppCredentials 130 | public let redirectURL: URL 131 | public let responseProvider: URLResponseProvider 132 | public let stateToken: String 133 | public let pcke: PKCEVerifier? 134 | } 135 | 136 | /// The output of this is a URL suitable for user authentication in a browser. 137 | public typealias AuthorizationURLProvider = @Sendable (AuthorizationURLParameters) async throws -> URL 138 | 139 | /// A function that processes the results of an authentication operation 140 | /// 141 | /// URL: The result of the Configuration.UserAuthenticator function 142 | /// AppCredentials: The credentials from Configuration.appCredentials 143 | /// URL: the authenticated URL from the OAuth service 144 | /// URLResponseProvider: the authenticator's provider 145 | public typealias LoginProvider = @Sendable (LoginProviderParameters) async throws -> Login 146 | public typealias RefreshProvider = @Sendable (Login, AppCredentials, URLResponseProvider) async throws -> Login 147 | public typealias ResponseStatusProvider = @Sendable ((Data, URLResponse)) throws -> ResponseStatus 148 | 149 | public let authorizationURLProvider: AuthorizationURLProvider 150 | public let loginProvider: LoginProvider 151 | public let refreshProvider: RefreshProvider? 152 | public let responseStatusProvider: ResponseStatusProvider 153 | public let dpopJWTGenerator: DPoPSigner.JWTGenerator? 154 | public let parConfiguration: PARConfiguration? 155 | public let pkce: PKCEVerifier? 156 | 157 | public init( 158 | parConfiguration: PARConfiguration? = nil, 159 | authorizationURLProvider: @escaping AuthorizationURLProvider, 160 | loginProvider: @escaping LoginProvider, 161 | refreshProvider: RefreshProvider? = nil, 162 | responseStatusProvider: @escaping ResponseStatusProvider = Self.refreshOrAuthorizeWhenUnauthorized, 163 | dpopJWTGenerator: DPoPSigner.JWTGenerator? = nil, 164 | pkce: PKCEVerifier? = nil 165 | 166 | ) { 167 | self.authorizationURLProvider = authorizationURLProvider 168 | self.loginProvider = loginProvider 169 | self.refreshProvider = refreshProvider 170 | self.responseStatusProvider = responseStatusProvider 171 | self.dpopJWTGenerator = dpopJWTGenerator 172 | self.parConfiguration = parConfiguration 173 | self.pkce = pkce 174 | } 175 | 176 | @Sendable 177 | public static func allResponsesValid(result: (Data, URLResponse)) throws -> ResponseStatus { 178 | return .valid 179 | } 180 | 181 | @Sendable 182 | public static func refreshOrAuthorizeWhenUnauthorized(result: (Data, URLResponse)) throws -> ResponseStatus { 183 | guard let response = result.1 as? HTTPURLResponse else { 184 | throw AuthenticatorError.httpResponseExpected 185 | } 186 | 187 | if response.statusCode == 401 { 188 | return .refresh 189 | } 190 | 191 | return .valid 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/PKCE.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct PKCEVerifier: Sendable { 4 | public struct Challenge: Hashable, Sendable { 5 | public let value: String 6 | public let method: String 7 | } 8 | public typealias HashFunction = @Sendable (String) -> String 9 | 10 | public let verifier: String 11 | public let challenge: Challenge 12 | public let hashFunction: HashFunction 13 | 14 | public static func randomString(length: Int) -> String { 15 | let characters = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 16 | 17 | var string = "" 18 | 19 | for _ in 0.. 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | NSPrivacyCollectedDataTypes 8 | 9 | NSPrivacyTrackingDomains 10 | 11 | NSPrivacyTracking 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Services/Bluesky.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// Find the spec here: https://atproto.com/specs/oauth 7 | public enum Bluesky { 8 | struct TokenRequest: Hashable, Sendable, Codable { 9 | public let code: String 10 | public let code_verifier: String 11 | public let redirect_uri: String 12 | public let grant_type: String 13 | public let client_id: String 14 | 15 | public init(code: String, code_verifier: String, redirect_uri: String, grant_type: String, client_id: String) { 16 | self.code = code 17 | self.code_verifier = code_verifier 18 | self.redirect_uri = redirect_uri 19 | self.grant_type = grant_type 20 | self.client_id = client_id 21 | } 22 | } 23 | 24 | struct RefreshTokenRequest: Hashable, Sendable, Codable { 25 | public let refresh_token: String 26 | public let redirect_uri: String 27 | public let grant_type: String 28 | public let client_id: String 29 | 30 | public init(refresh_token: String, redirect_uri: String, grant_type: String, client_id: String) { 31 | self.refresh_token = refresh_token 32 | self.redirect_uri = redirect_uri 33 | self.grant_type = grant_type 34 | self.client_id = client_id 35 | } 36 | } 37 | 38 | struct TokenResponse: Hashable, Sendable, Codable { 39 | public let access_token: String 40 | public let refresh_token: String? 41 | public let sub: String 42 | public let scope: String 43 | public let token_type: String 44 | public let expires_in: Int 45 | 46 | public func login(for issuingServer: String) -> Login { 47 | Login( 48 | accessToken: Token(value: access_token, expiresIn: expires_in), 49 | refreshToken: refresh_token.map { Token(value: $0) }, 50 | scopes: scope, 51 | issuingServer: issuingServer 52 | ) 53 | } 54 | } 55 | 56 | public static func tokenHandling( 57 | account: String?, 58 | server: ServerMetadata, 59 | jwtGenerator: @escaping DPoPSigner.JWTGenerator, 60 | pkce: PKCEVerifier 61 | ) -> TokenHandling { 62 | TokenHandling( 63 | parConfiguration: PARConfiguration( 64 | url: URL(string: server.pushedAuthorizationRequestEndpoint)!, 65 | parameters: { if let account { ["login_hint": account] } else { [:] } }() 66 | ), 67 | authorizationURLProvider: authorizionURLProvider(server: server), 68 | loginProvider: loginProvider(server: server), 69 | refreshProvider: refreshProvider(server: server), 70 | dpopJWTGenerator: jwtGenerator, 71 | pkce: pkce 72 | ) 73 | } 74 | 75 | #if canImport(CryptoKit) 76 | public static func tokenHandling( 77 | account: String?, 78 | server: ServerMetadata, 79 | jwtGenerator: @escaping DPoPSigner.JWTGenerator 80 | ) -> TokenHandling { 81 | tokenHandling( 82 | account: account, 83 | server: server, 84 | jwtGenerator: jwtGenerator, 85 | pkce: PKCEVerifier() 86 | ) 87 | } 88 | #endif 89 | 90 | private static func authorizionURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { 91 | return { params in 92 | var components = URLComponents(string: server.authorizationEndpoint) 93 | 94 | guard let parRequestURI = params.parRequestURI else { 95 | throw AuthenticatorError.parRequestURIMissing 96 | } 97 | 98 | components?.queryItems = [ 99 | URLQueryItem(name: "request_uri", value: parRequestURI), 100 | URLQueryItem(name: "client_id", value: params.credentials.clientId), 101 | ] 102 | 103 | guard let url = components?.url else { 104 | throw AuthenticatorError.missingAuthorizationURL 105 | } 106 | 107 | return url 108 | } 109 | } 110 | 111 | private static func loginProvider(server: ServerMetadata) -> TokenHandling.LoginProvider { 112 | return { params in 113 | // decode the params in the redirectURL 114 | guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { 115 | throw AuthenticatorError.missingTokenURL 116 | } 117 | 118 | guard 119 | let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, 120 | let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, 121 | let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value 122 | else { 123 | throw AuthenticatorError.missingAuthorizationCode 124 | } 125 | 126 | if state != params.stateToken { 127 | throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) 128 | } 129 | 130 | // and use them (plus just a little more) to construct the token request 131 | guard let tokenURL = URL(string: server.tokenEndpoint) else { 132 | throw AuthenticatorError.missingTokenURL 133 | } 134 | 135 | guard let verifier = params.pcke?.verifier else { 136 | throw AuthenticatorError.pkceRequired 137 | } 138 | 139 | let tokenRequest = TokenRequest( 140 | code: authCode, 141 | code_verifier: verifier, 142 | redirect_uri: params.credentials.callbackURL.absoluteString, 143 | grant_type: "authorization_code", 144 | client_id: params.credentials.clientId // is this field truly necessary? 145 | ) 146 | 147 | var request = URLRequest(url: tokenURL) 148 | 149 | request.httpMethod = "POST" 150 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 151 | request.httpBody = try JSONEncoder().encode(tokenRequest) 152 | 153 | let (data, response) = try await params.responseProvider(request) 154 | 155 | print("data:", String(decoding: data, as: UTF8.self)) 156 | print("response:", response) 157 | 158 | let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) 159 | 160 | guard tokenResponse.token_type == "DPoP" else { 161 | throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) 162 | } 163 | 164 | return tokenResponse.login(for: iss) 165 | } 166 | } 167 | 168 | private static func refreshProvider(server: ServerMetadata) -> TokenHandling.RefreshProvider { 169 | { login, credentials, responseProvider -> Login in 170 | guard let refreshToken = login.refreshToken?.value else { 171 | throw AuthenticatorError.refreshNotPossible 172 | } 173 | 174 | guard let tokenURL = URL(string: server.tokenEndpoint) else { 175 | throw AuthenticatorError.missingTokenURL 176 | } 177 | 178 | let tokenRequest = RefreshTokenRequest( 179 | refresh_token: refreshToken, 180 | redirect_uri: credentials.callbackURL.absoluteString, 181 | grant_type: "refresh_token", 182 | client_id: credentials.clientId // is this field truly necessary? 183 | ) 184 | 185 | var request = URLRequest(url: tokenURL) 186 | 187 | request.httpMethod = "POST" 188 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 189 | request.httpBody = try JSONEncoder().encode(tokenRequest) 190 | 191 | let (data, response) = try await responseProvider(request) 192 | 193 | // make sure that we got a successful HTTP response 194 | guard 195 | let httpResponse = response as? HTTPURLResponse, 196 | httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 197 | else { 198 | print("data:", String(decoding: data, as: UTF8.self)) 199 | print("response:", response) 200 | 201 | throw AuthenticatorError.refreshNotPossible 202 | } 203 | 204 | let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) 205 | 206 | guard tokenResponse.token_type == "DPoP" else { 207 | throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) 208 | } 209 | 210 | return tokenResponse.login(for: server.issuer) 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Services/GitHub.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// OAuth details for github.com 7 | /// 8 | /// GitHub supports two different kinds of integrations, called "OAuth Apps" and "GitHub Apps". Make sure to check which kind you need, as they differ in their authentication requirements. 9 | /// 10 | /// Check out https://docs.github.com/en/apps/creating-github-apps/creating-github-apps/differences-between-github-apps-and-oauth-apps 11 | public enum GitHub { 12 | static let host = "github.com" 13 | 14 | struct AppAuthResponse: Codable, Hashable, Sendable { 15 | let accessToken: String 16 | let expiresIn: Int 17 | let refreshToken: String 18 | let refreshTokenExpiresIn: Int 19 | let tokenType: String 20 | let scope: String 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case accessToken = "access_token" 24 | case expiresIn = "expires_in" 25 | case refreshToken = "refresh_token" 26 | case refreshTokenExpiresIn = "refresh_token_expires_in" 27 | case tokenType = "token_type" 28 | case scope 29 | } 30 | 31 | var login: Login { 32 | Login(accessToken: .init(value: accessToken, expiresIn: expiresIn), 33 | refreshToken: .init(value: refreshToken, expiresIn: refreshTokenExpiresIn)) 34 | } 35 | } 36 | 37 | struct OAuthResponse: Codable, Hashable, Sendable { 38 | let accessToken: String 39 | let tokenType: String 40 | let scope: String 41 | 42 | enum CodingKeys: String, CodingKey { 43 | case accessToken = "access_token" 44 | case tokenType = "token_type" 45 | case scope 46 | } 47 | 48 | var login: Login { 49 | Login(token: accessToken) 50 | } 51 | } 52 | 53 | public struct UserTokenParameters: Sendable { 54 | public let state: String? 55 | public let login: String? 56 | public let allowSignup: Bool? 57 | 58 | public init(state: String? = nil, login: String? = nil, allowSignup: Bool? = nil) { 59 | self.state = state 60 | self.login = login 61 | self.allowSignup = allowSignup 62 | } 63 | } 64 | 65 | /// TokenHandling for GitHub Apps 66 | public static func gitHubAppTokenHandling(with parameters: UserTokenParameters = .init()) -> TokenHandling { 67 | TokenHandling( 68 | authorizationURLProvider: authorizationURLProvider(with: parameters), 69 | loginProvider: gitHubAppLoginProvider, 70 | refreshProvider: refreshProvider 71 | ) 72 | } 73 | 74 | /// TokenHandling for OAuth Apps 75 | public static func OAuthAppTokenHandling() -> TokenHandling { 76 | TokenHandling( 77 | authorizationURLProvider: authorizationURLProvider(with: .init()), 78 | loginProvider: OAuthAppLoginProvider 79 | ) 80 | } 81 | 82 | static func authorizationURLProvider(with parameters: UserTokenParameters) -> TokenHandling.AuthorizationURLProvider { 83 | return { params in 84 | let credentials = params.credentials 85 | 86 | var urlBuilder = URLComponents() 87 | 88 | urlBuilder.scheme = "https" 89 | urlBuilder.host = host 90 | urlBuilder.path = "/login/oauth/authorize" 91 | urlBuilder.queryItems = [ 92 | URLQueryItem(name: "client_id", value: credentials.clientId), 93 | URLQueryItem(name: "redirect_uri", value: credentials.callbackURL.absoluteString), 94 | URLQueryItem(name: "scope", value: credentials.scopeString), 95 | ] 96 | 97 | if let state = parameters.state { 98 | urlBuilder.queryItems?.append(URLQueryItem(name: "state", value: state)) 99 | } 100 | 101 | guard let url = urlBuilder.url else { 102 | throw AuthenticatorError.missingAuthorizationURL 103 | } 104 | 105 | return url 106 | } 107 | } 108 | 109 | static func authenticationRequest(with url: URL, appCredentials: AppCredentials) throws -> URLRequest { 110 | let code = try url.authorizationCode 111 | 112 | var urlBuilder = URLComponents() 113 | 114 | urlBuilder.scheme = "https" 115 | urlBuilder.host = host 116 | urlBuilder.path = "/login/oauth/access_token" 117 | urlBuilder.queryItems = [ 118 | URLQueryItem(name: "client_id", value: appCredentials.clientId), 119 | URLQueryItem(name: "client_secret", value: appCredentials.clientPassword), 120 | URLQueryItem(name: "redirect_uri", value: appCredentials.callbackURL.absoluteString), 121 | URLQueryItem(name: "code", value: code), 122 | ] 123 | 124 | guard let url = urlBuilder.url else { 125 | throw AuthenticatorError.missingTokenURL 126 | } 127 | 128 | var request = URLRequest(url: url) 129 | 130 | request.httpMethod = "POST" 131 | request.setValue("application/json", forHTTPHeaderField: "Accept") 132 | 133 | return request 134 | } 135 | 136 | @Sendable 137 | static func gitHubAppLoginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { 138 | let request = try authenticationRequest(with: params.authorizationURL, appCredentials: params.credentials) 139 | 140 | let (data, _) = try await params.responseProvider(request) 141 | 142 | let response = try JSONDecoder().decode(GitHub.AppAuthResponse.self, from: data) 143 | 144 | return response.login 145 | } 146 | 147 | @Sendable 148 | static func OAuthAppLoginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { 149 | let request = try authenticationRequest(with: params.authorizationURL, appCredentials: params.credentials) 150 | 151 | let (data, _) = try await params.responseProvider(request) 152 | 153 | let response = try JSONDecoder().decode(GitHub.OAuthResponse.self, from: data) 154 | 155 | return response.login 156 | } 157 | 158 | @Sendable 159 | static func refreshProvider(login: Login, credentials: AppCredentials, urlLoader: URLResponseProvider) async throws -> Login { 160 | // TODO: GitHub Apps actually do support refresh 161 | throw AuthenticatorError.refreshUnsupported 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Services/GoogleAPI.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public struct GoogleAPI { 7 | // Define scheme, host and query item names 8 | public static let scheme: String = "https" 9 | static let authorizeHost: String = "accounts.google.com" 10 | static let authorizePath: String = "/o/oauth2/auth" 11 | static let tokenHost: String = "accounts.google.com" 12 | static let tokenPath: String = "/o/oauth2/token" 13 | 14 | static let clientIDKey: String = "client_id" 15 | static let clientSecretKey: String = "client_secret" 16 | static let redirectURIKey: String = "redirect_uri" 17 | 18 | static let responseTypeKey: String = "response_type" 19 | static let responseTypeCode: String = "code" 20 | 21 | static let scopeKey: String = "scope" 22 | static let includeGrantedScopeKey: String = "include_granted_scopes" 23 | static let loginHint: String = "login_hint" 24 | 25 | static let codeKey: String = "code" 26 | static let refreshTokenKey: String = "refresh_token" 27 | 28 | static let grantTypeKey: String = "grant_type" 29 | static let grantTypeAuthorizationCode: String = "authorization_code" 30 | static let grantTypeRefreshToken: String = "refresh_token" 31 | 32 | struct OAuthResponse: Codable, Hashable, Sendable { 33 | let accessToken: String 34 | let refreshToken: String? // When not using offline mode, no refreshToken is provided 35 | let scope: String 36 | let tokenType: String 37 | let expiresIn: Int // Access Token validity in seconds 38 | 39 | enum CodingKeys: String, CodingKey { 40 | case accessToken = "access_token" 41 | case refreshToken = "refresh_token" 42 | case scope 43 | case tokenType = "token_type" 44 | case expiresIn = "expires_in" 45 | } 46 | 47 | var login: Login { 48 | var login = Login(accessToken: .init(value: accessToken, expiresIn: expiresIn)) 49 | 50 | // Set the refresh token if we have one 51 | if let refreshToken = refreshToken { 52 | login.refreshToken = .init(value: refreshToken) 53 | } 54 | 55 | // Set the authorized scopes from the OAuthResponse if present 56 | if !self.scope.isEmpty { 57 | login.scopes = self.scope 58 | } 59 | 60 | return login 61 | } 62 | } 63 | 64 | /// Optional Google API Parameters for authorization request 65 | public struct GoogleAPIParameters: Sendable { 66 | public var includeGrantedScopes: Bool 67 | public var loginHint: String? 68 | 69 | public init() { 70 | self.includeGrantedScopes = true 71 | self.loginHint = nil 72 | } 73 | 74 | public init(includeGrantedScopes: Bool, loginHint: String?) { 75 | self.includeGrantedScopes = includeGrantedScopes 76 | self.loginHint = loginHint 77 | } 78 | } 79 | 80 | public static func googleAPITokenHandling(with parameters: GoogleAPIParameters = .init()) -> TokenHandling { 81 | TokenHandling(authorizationURLProvider: Self.authorizationURLProvider(with: parameters), 82 | loginProvider: Self.loginProvider, 83 | refreshProvider: Self.refreshProvider()) 84 | } 85 | 86 | /// This is part 1 of the OAuth process 87 | /// 88 | /// Will request an authentication `code` based on the acceptance by the user 89 | public static func authorizationURLProvider(with parameters: GoogleAPIParameters) -> TokenHandling.AuthorizationURLProvider { 90 | return { params in 91 | let credentials = params.credentials 92 | 93 | var urlBuilder = URLComponents() 94 | 95 | urlBuilder.scheme = GoogleAPI.scheme 96 | urlBuilder.host = GoogleAPI.authorizeHost 97 | urlBuilder.path = GoogleAPI.authorizePath 98 | urlBuilder.queryItems = [ 99 | URLQueryItem(name: GoogleAPI.clientIDKey, value: credentials.clientId), 100 | URLQueryItem(name: GoogleAPI.redirectURIKey, value: credentials.callbackURL.absoluteString), 101 | URLQueryItem(name: GoogleAPI.responseTypeKey, value: GoogleAPI.responseTypeCode), 102 | URLQueryItem(name: GoogleAPI.scopeKey, value: credentials.scopeString), 103 | URLQueryItem(name: GoogleAPI.includeGrantedScopeKey, value: String(parameters.includeGrantedScopes)) 104 | ] 105 | 106 | // Add login hint if provided 107 | if let loginHint = parameters.loginHint { 108 | urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.loginHint, value: loginHint)) 109 | } 110 | 111 | guard let url = urlBuilder.url else { 112 | throw AuthenticatorError.missingAuthorizationURL 113 | } 114 | 115 | return url 116 | } 117 | } 118 | 119 | /// This is part 2 of the OAuth process 120 | /// 121 | /// The `code` is exchanged for an access / refresh token pair using the granted scope in part 1 122 | static func authenticationRequest(url: URL, appCredentials: AppCredentials) throws -> URLRequest { 123 | let code = try url.authorizationCode 124 | 125 | // It's possible the user will decide to grant less scopes than requested by the app. 126 | // The actual granted scopes will be recorded in the Login object upon code exchange... 127 | let grantedScope = try url.grantedScope 128 | 129 | /* -- This is no longer necessary but kept as a reference -- 130 | let grantedScopeItems = grantedScope.components(separatedBy: " ") 131 | if appCredentials.scopes.count > grantedScopeItems.count { 132 | // Here we just 133 | os_log(.info, "[Authentication] Granted scopes less than requested scopes") 134 | } 135 | */ 136 | 137 | // Regardless if we want to move forward, we need to supply the granted scopes. 138 | // If we don't, the tokens will not be issued and an error will occur 139 | // The application can then later inspect the Login object and decide how to handle a reduce OAuth scope 140 | var urlBuilder = URLComponents() 141 | urlBuilder.scheme = GoogleAPI.scheme 142 | urlBuilder.host = GoogleAPI.tokenHost 143 | urlBuilder.path = GoogleAPI.tokenPath 144 | urlBuilder.queryItems = [ 145 | URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeAuthorizationCode), 146 | URLQueryItem(name: GoogleAPI.clientIDKey, value: appCredentials.clientId), 147 | URLQueryItem(name: GoogleAPI.redirectURIKey, value: appCredentials.callbackURL.absoluteString), 148 | URLQueryItem(name: GoogleAPI.codeKey, value: code), 149 | URLQueryItem(name: GoogleAPI.scopeKey, value: grantedScope) // See above for grantedScope explanation 150 | ] 151 | 152 | // Add clientSecret if supplied (not empty) 153 | if !appCredentials.clientPassword.isEmpty { 154 | urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.clientSecretKey, value: appCredentials.clientPassword)) 155 | } 156 | 157 | guard let url = urlBuilder.url else { 158 | throw AuthenticatorError.missingTokenURL 159 | } 160 | 161 | var request = URLRequest(url: url) 162 | request.httpMethod = "POST" 163 | request.setValue("application/json", forHTTPHeaderField: "Accept") 164 | 165 | return request 166 | } 167 | 168 | @Sendable 169 | static func loginProvider(params: TokenHandling.LoginProviderParameters) async throws -> Login { 170 | let request = try authenticationRequest(url: params.redirectURL, appCredentials: params.credentials) 171 | 172 | let (data, _) = try await params.responseProvider(request) 173 | 174 | do { 175 | let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) 176 | return response.login 177 | } 178 | catch let decodingError as DecodingError { 179 | let msg = decodingError.failureReason ?? decodingError.localizedDescription 180 | print("Reponse from AuthenticationProvider is not conformed to provided response format. ", msg) 181 | throw decodingError 182 | } 183 | } 184 | 185 | /// Token Refreshing 186 | /// - Create the request that will refresh the access token from the information in the Login 187 | /// 188 | /// - Parameters: 189 | /// - login: The current Login object containing the refresh token 190 | /// - appCredentials: The Application credentials 191 | /// - Returns: The URLRequest to refresh the access token 192 | static func authenticationRefreshRequest(login: Login, appCredentials: AppCredentials) throws -> URLRequest { 193 | guard let refreshToken = login.refreshToken, 194 | !refreshToken.value.isEmpty else { throw AuthenticatorError.missingRefreshToken } 195 | 196 | var urlBuilder = URLComponents() 197 | 198 | urlBuilder.scheme = GoogleAPI.scheme 199 | urlBuilder.host = GoogleAPI.tokenHost 200 | urlBuilder.path = GoogleAPI.tokenPath 201 | urlBuilder.queryItems = [ 202 | URLQueryItem(name: GoogleAPI.clientIDKey, value: appCredentials.clientId), 203 | URLQueryItem(name: GoogleAPI.refreshTokenKey, value: refreshToken.value), 204 | URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeRefreshToken), 205 | ] 206 | 207 | guard let url = urlBuilder.url else { 208 | throw AuthenticatorError.missingTokenURL 209 | } 210 | 211 | var request = URLRequest(url: url) 212 | request.httpMethod = "POST" 213 | request.setValue("application/json", forHTTPHeaderField: "Accept") 214 | 215 | return request 216 | } 217 | 218 | static func refreshProvider() -> TokenHandling.RefreshProvider { 219 | return { login, appCredentials, urlLoader in 220 | let request = try authenticationRefreshRequest(login: login, appCredentials: appCredentials) 221 | let (data, _) = try await urlLoader(request) 222 | 223 | do { 224 | let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) 225 | return response.login 226 | } 227 | catch let decodingError as DecodingError { 228 | let msg = decodingError.failureReason ?? decodingError.localizedDescription 229 | print("Non-conformant response from AuthenticationProvider:", msg) 230 | 231 | throw decodingError 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/Services/Mastodon.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public struct Mastodon { 7 | 8 | public static let scheme: String = "https" 9 | static let authorizePath: String = "/oauth/authorize" 10 | static let tokenPath: String = "/oauth/token" 11 | static let appRegistrationPath: String = "/api/v1/apps" 12 | 13 | static let clientNameKey: String = "client_name" 14 | static let clientIDKey: String = "client_id" 15 | static let clientSecretKey: String = "client_secret" 16 | static let redirectURIKey: String = "redirect_uri" 17 | static let redirectURIsKey: String = "redirect_uris" 18 | static let responseTypeKey: String = "response_type" 19 | static let scopeKey: String = "scope" 20 | static let scopesKey: String = "scopes" 21 | static let codeKey: String = "code" 22 | static let grantTypeKey: String = "grant_type" 23 | static let grantTypeAuthorizationCode: String = "authorization_code" 24 | static let responseTypeCode: String = "code" 25 | 26 | struct AppAuthResponse: Codable, Hashable, Sendable { 27 | let accessToken: String 28 | let scope: String 29 | let tokenType: String 30 | let createdAt: Int 31 | 32 | enum CodingKeys: String, CodingKey { 33 | case accessToken = "access_token" 34 | case scope 35 | case tokenType = "token_type" 36 | case createdAt = "created_at" 37 | } 38 | 39 | var login: Login { 40 | Login(accessToken: .init(value: accessToken)) 41 | } 42 | } 43 | 44 | public struct AppRegistrationResponse: Codable, Sendable { 45 | let id: String 46 | public let clientID: String 47 | public let clientSecret: String 48 | public let redirectURI: String? 49 | 50 | let name: String? 51 | let website: String? 52 | let vapidKey: String? 53 | 54 | enum CodingKeys: String, CodingKey { 55 | case id 56 | case clientID = "client_id" 57 | case clientSecret = "client_secret" 58 | case redirectURI = "redirect_uri" 59 | case name 60 | case website 61 | case vapidKey = "vapid_key" 62 | } 63 | } 64 | 65 | public struct UserTokenParameters: Sendable { 66 | public let host: String 67 | public let clientName: String 68 | public let redirectURI: String 69 | public let scopes: [String] 70 | 71 | public init(host: String, clientName: String, redirectURI: String, scopes: [String]) { 72 | self.host = host 73 | self.clientName = clientName 74 | self.redirectURI = redirectURI 75 | self.scopes = scopes 76 | } 77 | } 78 | 79 | public static func tokenHandling(with parameters: UserTokenParameters) -> TokenHandling { 80 | TokenHandling( 81 | authorizationURLProvider: authorizationURLProvider(with: parameters), 82 | loginProvider: loginProvider(with: parameters), 83 | refreshProvider: refreshProvider(with: parameters) 84 | ) 85 | } 86 | 87 | static func authorizationURLProvider(with parameters: UserTokenParameters) -> TokenHandling.AuthorizationURLProvider { 88 | return { params in 89 | let credentials = params.credentials 90 | 91 | var urlBuilder = URLComponents() 92 | 93 | urlBuilder.scheme = Mastodon.scheme 94 | urlBuilder.host = parameters.host 95 | urlBuilder.path = Mastodon.authorizePath 96 | urlBuilder.queryItems = [ 97 | URLQueryItem(name: Mastodon.clientIDKey, value: credentials.clientId), 98 | URLQueryItem(name: Mastodon.redirectURIKey, value: credentials.callbackURL.absoluteString), 99 | URLQueryItem(name: Mastodon.responseTypeKey, value: Mastodon.responseTypeCode), 100 | URLQueryItem(name: Mastodon.scopeKey, value: credentials.scopeString) 101 | ] 102 | 103 | guard let url = urlBuilder.url else { 104 | throw AuthenticatorError.missingAuthorizationURL 105 | } 106 | 107 | return url 108 | } 109 | } 110 | 111 | static func authenticationRequest(with parameters: UserTokenParameters, url: URL, appCredentials: AppCredentials) throws -> URLRequest { 112 | let code = try url.authorizationCode 113 | 114 | var urlBuilder = URLComponents() 115 | 116 | urlBuilder.scheme = Mastodon.scheme 117 | urlBuilder.host = parameters.host 118 | urlBuilder.path = Mastodon.tokenPath 119 | urlBuilder.queryItems = [ 120 | URLQueryItem(name: Mastodon.grantTypeKey, value: Mastodon.grantTypeAuthorizationCode), 121 | URLQueryItem(name: Mastodon.clientIDKey, value: appCredentials.clientId), 122 | URLQueryItem(name: Mastodon.clientSecretKey, value: appCredentials.clientPassword), 123 | URLQueryItem(name: Mastodon.redirectURIKey, value: appCredentials.callbackURL.absoluteString), 124 | URLQueryItem(name: Mastodon.codeKey, value: code), 125 | URLQueryItem(name: Mastodon.scopeKey, value: appCredentials.scopeString) 126 | ] 127 | 128 | guard let url = urlBuilder.url else { 129 | throw AuthenticatorError.missingTokenURL 130 | } 131 | 132 | var request = URLRequest(url: url) 133 | 134 | request.httpMethod = "POST" 135 | request.setValue("application/json", forHTTPHeaderField: "Accept") 136 | 137 | return request 138 | } 139 | 140 | static func loginProvider(with userParameters: UserTokenParameters) -> TokenHandling.LoginProvider { 141 | return { params in 142 | let request = try authenticationRequest(with: userParameters, url: params.redirectURL, appCredentials: params.credentials) 143 | 144 | let (data, _) = try await params.responseProvider(request) 145 | 146 | let response = try JSONDecoder().decode(Mastodon.AppAuthResponse.self, from: data) 147 | 148 | return response.login 149 | } 150 | } 151 | 152 | static func refreshProvider(with parameters: UserTokenParameters) -> TokenHandling.RefreshProvider { 153 | return { login, appCredentials, urlResponseProvider in 154 | throw AuthenticatorError.refreshUnsupported 155 | } 156 | } 157 | 158 | public static func register(with parameters: UserTokenParameters, urlLoader: URLResponseProvider) async throws -> AppRegistrationResponse { 159 | var urlBuilder = URLComponents() 160 | 161 | urlBuilder.scheme = Mastodon.scheme 162 | urlBuilder.host = parameters.host 163 | urlBuilder.path = Mastodon.appRegistrationPath 164 | urlBuilder.queryItems = [ 165 | URLQueryItem(name: Mastodon.clientNameKey, value: parameters.clientName), 166 | URLQueryItem(name: Mastodon.redirectURIsKey, value: parameters.redirectURI), 167 | URLQueryItem(name: Mastodon.scopesKey, value: parameters.scopes.joined(separator: " ")) 168 | ] 169 | 170 | guard let url = urlBuilder.url else { 171 | throw AuthenticatorError.missingTokenURL 172 | } 173 | 174 | var request = URLRequest(url: url) 175 | 176 | request.httpMethod = "POST" 177 | request.setValue("application/json", forHTTPHeaderField: "Accept") 178 | 179 | let (data, _) = try await urlLoader(request) 180 | let registrationResponse = try JSONDecoder().decode(AppRegistrationResponse.self, from: data) 181 | return registrationResponse 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/URL+QueryParams.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | public extension URL { 7 | func queryValues(named name: String) -> [String] { 8 | let components = URLComponents(url: self, resolvingAgainstBaseURL: false) 9 | let items = components?.queryItems?.filter({ $0.name == name }) 10 | return items?.compactMap { $0.value } ?? [] 11 | } 12 | 13 | var authorizationCode: String { 14 | get throws { 15 | guard let value = queryValues(named: "code").first else { 16 | throw AuthenticatorError.missingAuthorizationCode 17 | } 18 | 19 | return value 20 | } 21 | } 22 | 23 | /// 24 | /// The scope query parameter contains the authorized scopes by the user 25 | /// Typically used for the GoogleAPI 26 | var grantedScope: String { 27 | get throws { 28 | guard let value = queryValues(named: "scope").first else { 29 | throw AuthenticatorError.missingScope 30 | } 31 | 32 | return value 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/URLSession+ResponseProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | enum URLResponseProviderError: Error { 7 | case missingResponseComponents 8 | } 9 | 10 | extension URLSession { 11 | /// Convert a `URLSession` instance into a `URLResponseProvider`. 12 | public var responseProvider: URLResponseProvider { 13 | return { request in 14 | return try await withCheckedThrowingContinuation { continuation in 15 | let task = self.dataTask(with: request) { data, response, error in 16 | switch (data, response, error) { 17 | case (let data?, let response?, nil): 18 | continuation.resume(returning: (data, response)) 19 | case (_, _, let error?): 20 | continuation.resume(throwing: error) 21 | case (_, _, nil): 22 | continuation.resume(throwing: URLResponseProviderError.missingResponseComponents) 23 | } 24 | } 25 | 26 | task.resume() 27 | } 28 | } 29 | } 30 | 31 | /// Convert a `URLSession` with a default configuration into a `URLResponseProvider`. 32 | public static var defaultProvider: URLResponseProvider { 33 | let session = URLSession(configuration: .default) 34 | 35 | return session.responseProvider 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/WebAuthenticationSession+Utility.swift: -------------------------------------------------------------------------------- 1 | #if canImport(AuthenticationServices) 2 | import AuthenticationServices 3 | import SwiftUI 4 | 5 | 6 | @available(macOS 13.3, iOS 16.4, watchOS 9.4, tvOS 16.4, *) 7 | extension WebAuthenticationSession { 8 | public func userAuthenticator(preferredBrowserSession: BrowserSession? = nil) -> Authenticator.UserAuthenticator { 9 | return { 10 | try await self.authenticate(using: $0, callbackURLScheme: $1, preferredBrowserSession: preferredBrowserSession) 11 | } 12 | } 13 | } 14 | 15 | #endif 16 | -------------------------------------------------------------------------------- /Sources/OAuthenticator/WellknownEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | enum MetadataError: Error { 7 | case urlInvalid 8 | } 9 | 10 | public struct ServerMetadata: Codable, Hashable, Sendable { 11 | public let issuer: String 12 | public let authorizationEndpoint: String 13 | public let tokenEndpoint: String 14 | public let responseTypesSupported: [String] 15 | public let grantTypesSupported: [String] 16 | public let codeChallengeMethodsSupported: [String] 17 | public let tokenEndpointAuthMethodsSupported: [String] 18 | public let tokenEndpointAuthSigningAlgValuesSupported: [String] 19 | public let scopesSupported: [String] 20 | public let authorizationResponseIssParameterSupported: Bool 21 | public let requirePushedAuthorizationRequests: Bool 22 | public let pushedAuthorizationRequestEndpoint: String 23 | public let dpopSigningAlgValuesSupported: [String] 24 | public let requireRequestUriRegistration: Bool 25 | public let clientIdMetadataDocumentSupported: Bool 26 | 27 | enum CodingKeys: String, CodingKey { 28 | case issuer 29 | case authorizationEndpoint = "authorization_endpoint" 30 | case tokenEndpoint = "token_endpoint" 31 | case responseTypesSupported = "response_types_supported" 32 | case grantTypesSupported = "grant_types_supported" 33 | case codeChallengeMethodsSupported = "code_challenge_methods_supported" 34 | case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" 35 | case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported" 36 | case scopesSupported = "scopes_supported" 37 | case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported" 38 | case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" 39 | case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 40 | case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 41 | case requireRequestUriRegistration = "require_request_uri_registration" 42 | case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" 43 | } 44 | 45 | public static func load(for host: String, provider: URLResponseProvider) async throws -> ServerMetadata { 46 | var components = URLComponents() 47 | 48 | components.scheme = "https" 49 | components.host = host 50 | components.path = "/.well-known/oauth-authorization-server" 51 | components.queryItems = [ 52 | URLQueryItem(name: "Accept", value: "application/json") 53 | ] 54 | 55 | guard let url = components.url else { 56 | throw MetadataError.urlInvalid 57 | } 58 | 59 | let (data, _) = try await provider(URLRequest(url: url)) 60 | 61 | return try JSONDecoder().decode(ServerMetadata.self, from: data) 62 | } 63 | } 64 | 65 | public struct ClientMetadata: Hashable, Codable, Sendable { 66 | public let clientId: String 67 | public let scope: String 68 | public let redirectURIs: [String] 69 | public let dpopBoundAccessTokens: Bool 70 | 71 | enum CodingKeys: String, CodingKey { 72 | case clientId = "client_id" 73 | case scope 74 | case redirectURIs = "redirect_uris" 75 | case dpopBoundAccessTokens = "dpop_bound_access_tokens" 76 | } 77 | 78 | public static func load(for endpoint: String, provider: URLResponseProvider) async throws -> ClientMetadata { 79 | guard let url = URL(string: endpoint) else { 80 | throw MetadataError.urlInvalid 81 | } 82 | 83 | let (data, _) = try await provider(URLRequest(url: url)) 84 | 85 | return try JSONDecoder().decode(ClientMetadata.self, from: data) 86 | } 87 | } 88 | 89 | extension ClientMetadata { 90 | public var credentials: AppCredentials { 91 | let url = redirectURIs.first.map({ URL(string: $0)! })! 92 | 93 | return AppCredentials( 94 | clientId: clientId, 95 | clientPassword: "", 96 | scopes: scope.components(separatedBy: " "), 97 | callbackURL: url 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/AuthenticatorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | import OAuthenticator 7 | 8 | enum AuthenticatorTestsError: Error { 9 | case disabled 10 | } 11 | 12 | final class MockURLResponseProvider: @unchecked Sendable { 13 | var responses: [Result<(Data, URLResponse), Error>] = [] 14 | private(set) var requests: [URLRequest] = [] 15 | private let lock = NSLock() 16 | 17 | init() { 18 | } 19 | 20 | func response(for request: URLRequest) throws -> (Data, URLResponse) { 21 | try lock.withLock { 22 | requests.append(request) 23 | 24 | return try responses.removeFirst().get() 25 | } 26 | } 27 | 28 | var responseProvider: URLResponseProvider { 29 | return { try self.response(for: $0) } 30 | } 31 | 32 | static let dummyResponse: (Data, URLResponse) = ( 33 | "hello".data(using: .utf8)!, 34 | URLResponse(url: URL(string: "https://test.com")!, mimeType: nil, expectedContentLength: 5, textEncodingName: nil) 35 | ) 36 | } 37 | 38 | final class AuthenticatorTests: XCTestCase { 39 | private static let mockCredentials = AppCredentials( 40 | clientId: "abc", 41 | clientPassword: "def", 42 | scopes: ["123"], 43 | callbackURL: URL(string: "my://callback")! 44 | ) 45 | 46 | @Sendable 47 | private static func disabledUserAuthenticator(url: URL, user: String) throws -> URL { 48 | throw AuthenticatorTestsError.disabled 49 | } 50 | 51 | @Sendable 52 | private static func disabledAuthorizationURLProvider(parameters: TokenHandling.AuthorizationURLParameters) throws -> URL { 53 | throw AuthenticatorTestsError.disabled 54 | } 55 | 56 | @Sendable 57 | private static func disabledLoginProvider(parameters: TokenHandling.LoginProviderParameters) throws -> Login { 58 | throw AuthenticatorTestsError.disabled 59 | } 60 | 61 | @MainActor 62 | func testInitialLogin() async throws { 63 | let authedLoadExp = expectation(description: "load url") 64 | 65 | let mockLoader: URLResponseProvider = { request in 66 | XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer TOKEN") 67 | authedLoadExp.fulfill() 68 | 69 | return MockURLResponseProvider.dummyResponse 70 | } 71 | 72 | let userAuthExp = expectation(description: "user auth") 73 | let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in 74 | userAuthExp.fulfill() 75 | XCTAssertEqual(url, URL(string: "my://auth?client_id=abc")!) 76 | XCTAssertEqual(scheme, "my") 77 | 78 | return URL(string: "my://login")! 79 | } 80 | 81 | let urlProvider: TokenHandling.AuthorizationURLProvider = { params in 82 | return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! 83 | } 84 | 85 | let loginProvider: TokenHandling.LoginProvider = { params in 86 | XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) 87 | 88 | return Login(token: "TOKEN") 89 | } 90 | 91 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, 92 | loginProvider: loginProvider, 93 | responseStatusProvider: TokenHandling.allResponsesValid) 94 | 95 | let retrieveTokenExp = expectation(description: "get token") 96 | let storeTokenExp = expectation(description: "save token") 97 | 98 | let storage = LoginStorage { 99 | retrieveTokenExp.fulfill() 100 | 101 | return nil 102 | } storeLogin: { 103 | XCTAssertEqual($0, Login(token: "TOKEN")) 104 | 105 | storeTokenExp.fulfill() 106 | } 107 | 108 | let config = Authenticator.Configuration( 109 | appCredentials: Self.mockCredentials, 110 | loginStorage: storage, 111 | // loginStorage: nil, 112 | tokenHandling: tokenHandling, 113 | // tokenHandling: TokenHandling( 114 | // authorizationURLProvider: { _ in 115 | // throw AuthenticatorTestsError.disabled 116 | // }, 117 | // loginProvider: { _, _, _, _ in 118 | // throw AuthenticatorTestsError.disabled 119 | // } 120 | // ), 121 | userAuthenticator: mockUserAuthenticator 122 | ) 123 | 124 | let auth = Authenticator(config: config, urlLoader: mockLoader) 125 | 126 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 127 | 128 | await fulfillment(of: [retrieveTokenExp, userAuthExp, storeTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true) 129 | } 130 | 131 | @MainActor 132 | func testExistingLogin() async throws { 133 | let authedLoadExp = expectation(description: "load url") 134 | 135 | let mockLoader: URLResponseProvider = { request in 136 | XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer TOKEN") 137 | authedLoadExp.fulfill() 138 | 139 | return MockURLResponseProvider.dummyResponse 140 | } 141 | 142 | let tokenHandling = TokenHandling( 143 | authorizationURLProvider: Self.disabledAuthorizationURLProvider, 144 | loginProvider: Self.disabledLoginProvider, 145 | responseStatusProvider: TokenHandling.allResponsesValid 146 | ) 147 | 148 | let retrieveTokenExp = expectation(description: "get token") 149 | let storage = LoginStorage { 150 | retrieveTokenExp.fulfill() 151 | 152 | return Login(token: "TOKEN") 153 | } storeLogin: { _ in 154 | XCTFail() 155 | } 156 | 157 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 158 | loginStorage: storage, 159 | tokenHandling: tokenHandling, 160 | userAuthenticator: Self.disabledUserAuthenticator) 161 | 162 | let auth = Authenticator(config: config, urlLoader: mockLoader) 163 | 164 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 165 | 166 | await fulfillment(of: [retrieveTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true) 167 | } 168 | 169 | @MainActor 170 | func testExpiredTokenRefresh() async throws { 171 | let authedLoadExp = expectation(description: "load url") 172 | 173 | let mockLoader: URLResponseProvider = { request in 174 | XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED") 175 | authedLoadExp.fulfill() 176 | 177 | return MockURLResponseProvider.dummyResponse 178 | } 179 | 180 | let refreshExp = expectation(description: "refresh") 181 | let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in 182 | XCTAssertEqual(login.accessToken.value, "EXPIRED") 183 | XCTAssertEqual(login.refreshToken?.value, "REFRESH") 184 | 185 | refreshExp.fulfill() 186 | 187 | return Login(token: "REFRESHED") 188 | } 189 | 190 | let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider, 191 | loginProvider: Self.disabledLoginProvider, 192 | refreshProvider: refreshProvider, 193 | responseStatusProvider: TokenHandling.allResponsesValid) 194 | 195 | let retrieveTokenExp = expectation(description: "get token") 196 | let storeTokenExp = expectation(description: "save token") 197 | 198 | let storage = LoginStorage { 199 | retrieveTokenExp.fulfill() 200 | 201 | return Login(accessToken: Token(value: "EXPIRED", expiry: .distantPast), 202 | refreshToken: Token(value: "REFRESH")) 203 | } storeLogin: { login in 204 | storeTokenExp.fulfill() 205 | 206 | XCTAssertEqual(login.accessToken.value, "REFRESHED") 207 | } 208 | 209 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 210 | loginStorage: storage, 211 | tokenHandling: tokenHandling, 212 | userAuthenticator: Self.disabledUserAuthenticator) 213 | 214 | let auth = Authenticator(config: config, urlLoader: mockLoader) 215 | 216 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 217 | 218 | await fulfillment(of: [retrieveTokenExp, refreshExp, storeTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true) 219 | } 220 | 221 | @MainActor 222 | func testManualAuthentication() async throws { 223 | let urlProvider: TokenHandling.AuthorizationURLProvider = { parameters in 224 | return URL(string: "my://auth?client_id=\(parameters.credentials.clientId)")! 225 | } 226 | 227 | let loginProvider: TokenHandling.LoginProvider = { parameters in 228 | XCTAssertEqual(parameters.redirectURL, URL(string: "my://login")!) 229 | 230 | return Login(token: "TOKEN") 231 | } 232 | 233 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, 234 | loginProvider: loginProvider, 235 | responseStatusProvider: TokenHandling.allResponsesValid) 236 | 237 | let userAuthExp = expectation(description: "user auth") 238 | let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in 239 | userAuthExp.fulfill() 240 | 241 | return URL(string: "my://login")! 242 | } 243 | 244 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 245 | tokenHandling: tokenHandling, 246 | mode: .manualOnly, 247 | userAuthenticator: mockUserAuthenticator) 248 | 249 | let loadExp = expectation(description: "load url") 250 | let mockLoader: URLResponseProvider = { request in 251 | loadExp.fulfill() 252 | 253 | return MockURLResponseProvider.dummyResponse 254 | } 255 | 256 | let auth = Authenticator(config: config, urlLoader: mockLoader) 257 | 258 | do { 259 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 260 | 261 | XCTFail() 262 | } catch AuthenticatorError.manualAuthenticationRequired { 263 | 264 | } catch { 265 | XCTFail() 266 | } 267 | 268 | // now we explicitly authenticate, and things should work 269 | try await auth.authenticate() 270 | 271 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 272 | 273 | await fulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) 274 | } 275 | 276 | func testManualAuthenticationWithSuccessResult() async throws { 277 | let urlProvider: TokenHandling.AuthorizationURLProvider = { params in 278 | return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! 279 | } 280 | 281 | let loginProvider: TokenHandling.LoginProvider = { params in 282 | XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) 283 | 284 | return Login(token: "TOKEN") 285 | } 286 | 287 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, 288 | loginProvider: loginProvider, 289 | responseStatusProvider: TokenHandling.allResponsesValid) 290 | 291 | let userAuthExp = expectation(description: "user auth") 292 | let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in 293 | userAuthExp.fulfill() 294 | 295 | return URL(string: "my://login")! 296 | } 297 | 298 | // This is the callback to obtain authentication results 299 | var authenticatedLogin: Login? 300 | let authenticationCallback: Authenticator.AuthenticationStatusHandler = { result in 301 | switch result { 302 | case .failure(_): 303 | XCTFail() 304 | case .success(let login): 305 | authenticatedLogin = login 306 | } 307 | } 308 | 309 | // Configure Authenticator with result callback 310 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 311 | tokenHandling: tokenHandling, 312 | mode: .manualOnly, 313 | userAuthenticator: mockUserAuthenticator, 314 | authenticationStatusHandler: authenticationCallback) 315 | 316 | let loadExp = expectation(description: "load url") 317 | let mockLoader: URLResponseProvider = { request in 318 | loadExp.fulfill() 319 | 320 | return MockURLResponseProvider.dummyResponse 321 | } 322 | 323 | let auth = Authenticator(config: config, urlLoader: mockLoader) 324 | // Explicitly authenticate and grab Login information after 325 | try await auth.authenticate() 326 | 327 | // Ensure our authenticatedLogin objet is available and contains the proper Token 328 | XCTAssertNotNil(authenticatedLogin) 329 | XCTAssertEqual(authenticatedLogin!, Login(token:"TOKEN")) 330 | 331 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 332 | 333 | await fulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) 334 | } 335 | 336 | // Test AuthenticationResultHandler with a failed UserAuthenticator 337 | func testManualAuthenticationWithFailedResult() async throws { 338 | let urlProvider: TokenHandling.AuthorizationURLProvider = { params in 339 | return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! 340 | } 341 | 342 | let loginProvider: TokenHandling.LoginProvider = { params in 343 | XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) 344 | 345 | return Login(token: "TOKEN") 346 | } 347 | 348 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, 349 | loginProvider: loginProvider, 350 | responseStatusProvider: TokenHandling.allResponsesValid) 351 | 352 | // This is the callback to obtain authentication results 353 | var authenticatedLogin: Login? 354 | let failureAuth = expectation(description: "auth failure") 355 | let authenticationCallback: Authenticator.AuthenticationStatusHandler = { result in 356 | switch result { 357 | case .failure(_): 358 | failureAuth.fulfill() 359 | authenticatedLogin = nil 360 | case .success(_): 361 | XCTFail() 362 | } 363 | } 364 | 365 | // Configure Authenticator with result callback 366 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 367 | tokenHandling: tokenHandling, 368 | mode: .manualOnly, 369 | userAuthenticator: Authenticator.failingUserAuthenticator, 370 | authenticationStatusHandler: authenticationCallback) 371 | 372 | let auth = Authenticator(config: config, urlLoader: nil) 373 | do { 374 | // Explicitly authenticate and grab Login information after 375 | try await auth.authenticate() 376 | 377 | // Ensure our authenticatedLogin objet is *not* available 378 | XCTAssertNil(authenticatedLogin) 379 | } 380 | catch let error as AuthenticatorError { 381 | XCTAssertEqual(error, AuthenticatorError.failingAuthenticatorUsed) 382 | } 383 | catch { 384 | throw error 385 | } 386 | 387 | await fulfillment(of: [failureAuth], timeout: 1.0, enforceOrder: true) 388 | } 389 | 390 | func testUnauthorizedRequestRefreshes() async throws { 391 | let requestedURL = URL(string: "https://example.com")! 392 | 393 | let mockLoader = MockURLResponseProvider() 394 | let mockData = "hello".data(using: .utf8)! 395 | 396 | mockLoader.responses = [ 397 | .success((Data(), HTTPURLResponse(url: requestedURL, statusCode: 401, httpVersion: nil, headerFields: nil)!)), 398 | .success((mockData, HTTPURLResponse(url: requestedURL, statusCode: 200, httpVersion: nil, headerFields: nil)!)), 399 | ] 400 | 401 | let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in 402 | return Login(token: "REFRESHED") 403 | } 404 | 405 | let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider, 406 | loginProvider: Self.disabledLoginProvider, 407 | refreshProvider: refreshProvider) 408 | 409 | let storage = LoginStorage { 410 | // ensure we actually try this one 411 | return Login(accessToken: Token(value: "EXPIRED", expiry: .distantFuture), 412 | refreshToken: Token(value: "REFRESH")) 413 | } storeLogin: { login in 414 | XCTAssertEqual(login.accessToken.value, "REFRESHED") 415 | } 416 | 417 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 418 | loginStorage: storage, 419 | tokenHandling: tokenHandling, 420 | userAuthenticator: Self.disabledUserAuthenticator) 421 | 422 | let auth = Authenticator(config: config, urlLoader: mockLoader.responseProvider) 423 | 424 | let (data, _) = try await auth.response(for: URLRequest(url: requestedURL)) 425 | 426 | XCTAssertEqual(data, mockData) 427 | XCTAssertEqual(mockLoader.requests.count, 2) 428 | XCTAssertEqual(mockLoader.requests[0].allHTTPHeaderFields!["Authorization"], "Bearer EXPIRED") 429 | XCTAssertEqual(mockLoader.requests[1].allHTTPHeaderFields!["Authorization"], "Bearer REFRESHED") 430 | } 431 | 432 | actor RequestContainer { 433 | private(set) var sentRequests: [URLRequest] = [] 434 | 435 | func addRequest(_ request: URLRequest) { 436 | self.sentRequests.append(request) 437 | } 438 | } 439 | 440 | @available(macOS 13.0, macCatalyst 16.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) 441 | @MainActor 442 | func testTokenExpiredAfterUseRefresh() async throws { 443 | var sentRequests: [URLRequest] = [] 444 | 445 | let mockLoader: URLResponseProvider = { @MainActor request in 446 | sentRequests.append(request) 447 | return MockURLResponseProvider.dummyResponse 448 | } 449 | 450 | var refreshedLogins: [Login] = [] 451 | let refreshProvider: TokenHandling.RefreshProvider = { @MainActor login, _, _ in 452 | refreshedLogins.append(login) 453 | 454 | return Login(token: "REFRESHED") 455 | } 456 | 457 | let tokenHandling = TokenHandling( 458 | authorizationURLProvider: Self.disabledAuthorizationURLProvider, 459 | loginProvider: Self.disabledLoginProvider, 460 | refreshProvider: refreshProvider, 461 | responseStatusProvider: TokenHandling.allResponsesValid 462 | ) 463 | 464 | let storedLogin = Login( 465 | accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(1)), 466 | refreshToken: Token(value: "REFRESH") 467 | ) 468 | var loadLoginCount = 0 469 | var savedLogins: [Login] = [] 470 | let storage = LoginStorage { @MainActor in 471 | loadLoginCount += 1 472 | 473 | return storedLogin 474 | } storeLogin: { @MainActor login in 475 | savedLogins.append(login) 476 | } 477 | 478 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, 479 | loginStorage: storage, 480 | tokenHandling: tokenHandling, 481 | userAuthenticator: Self.disabledUserAuthenticator) 482 | 483 | let auth = Authenticator(config: config, urlLoader: mockLoader) 484 | 485 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 486 | let sentRequestsOne = sentRequests 487 | 488 | XCTAssertEqual(sentRequestsOne.count, 1, "First request should be sent") 489 | XCTAssertEqual(sentRequestsOne.first?.value(forHTTPHeaderField: "Authorization"), "Bearer EXPIRE SOON", "Non expired token should be used for first request") 490 | XCTAssertTrue(refreshedLogins.isEmpty, "Token should not be refreshed after first request") 491 | XCTAssertEqual(loadLoginCount, 1, "Login should be loaded from storage once") 492 | XCTAssertTrue(savedLogins.isEmpty, "Login storage should not be updated after first request") 493 | 494 | // Let the token expire 495 | try await Task.sleep(for: .seconds(1)) 496 | 497 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 498 | let sentRequestsTwo = sentRequests 499 | 500 | XCTAssertEqual(refreshedLogins.count, 1, "Token should be refreshed") 501 | XCTAssertEqual(refreshedLogins.first?.accessToken.value, "EXPIRE SOON", "Expired token should be passed to refresh call") 502 | XCTAssertEqual(refreshedLogins.first?.refreshToken?.value, "REFRESH", "Refresh token should be passed to refresh call") 503 | XCTAssertEqual(loadLoginCount, 2, "New login should be loaded from storage") 504 | XCTAssertEqual(sentRequestsTwo.count, 2, "Second request should be sent") 505 | let secondRequest = sentRequestsTwo.dropFirst().first 506 | XCTAssertEqual(secondRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for second request") 507 | XCTAssertEqual(savedLogins.first?.accessToken.value, "REFRESHED", "Refreshed token should be saved to storage") 508 | 509 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) 510 | let sentRequestsThree = sentRequests 511 | 512 | XCTAssertEqual(refreshedLogins.count, 1, "No additional refreshes should happen") 513 | XCTAssertEqual(loadLoginCount, 2, "No additional login loads should happen") 514 | XCTAssertEqual(sentRequestsThree.count, 3, "Third request should be sent") 515 | let thirdRequest = sentRequestsThree.dropFirst(2).first 516 | XCTAssertEqual(thirdRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for third request") 517 | XCTAssertEqual(savedLogins.count, 1, "No additional logins should be saved to storage") 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/DPoPSignerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | import Testing 6 | 7 | import OAuthenticator 8 | 9 | struct ExamplePayload: Codable, Hashable, Sendable { 10 | let value: String 11 | } 12 | 13 | struct DPoPSignerTests { 14 | @MainActor 15 | @Test func basicSignature() async throws { 16 | let signer = DPoPSigner() 17 | 18 | var request = URLRequest(url: URL(string: "https://example.com")!) 19 | 20 | try await signer.authenticateRequest( 21 | &request, 22 | isolation: MainActor.shared, 23 | using: { _ in "my_fake_jwt" }, 24 | token: "token", 25 | tokenHash: "token_hash", 26 | issuer: "issuer" 27 | ) 28 | 29 | let headers = try #require(request.allHTTPHeaderFields) 30 | 31 | #expect(headers["Authorization"] == "DPoP token") 32 | #expect(headers["DPoP"] == "my_fake_jwt") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/GitHubTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OAuthenticator 3 | 4 | final class GitHubTests: XCTestCase { 5 | func testAppResponseDecode() throws { 6 | let content = """ 7 | {"access_token": "abc", "expires_in": 5, "refresh_token": "def", "refresh_token_expires_in": 5, "scope": "", "token_type": "bearer"} 8 | """ 9 | let data = try XCTUnwrap(content.data(using: .utf8)) 10 | let response = try JSONDecoder().decode(GitHub.AppAuthResponse.self, from: data) 11 | 12 | XCTAssertEqual(response.accessToken, "abc") 13 | 14 | let login = response.login 15 | XCTAssertEqual(login.accessToken.value, "abc") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/GoogleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | @testable import OAuthenticator 7 | 8 | final class GoogleTests: XCTestCase { 9 | func testOAuthResponseDecode() throws { 10 | let content = """ 11 | {"access_token": "abc", "expires_in": 3, "refresh_token": "def", "scope": "https://gmail.scope", "token_type": "bearer"} 12 | """ 13 | let data = try XCTUnwrap(content.data(using: .utf8)) 14 | let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) 15 | 16 | XCTAssertEqual(response.accessToken, "abc") 17 | 18 | let login = response.login 19 | XCTAssertEqual(login.accessToken.value, "abc") 20 | 21 | // Sleep until access token expires 22 | sleep(5) 23 | XCTAssert(!login.accessToken.valid) 24 | } 25 | 26 | func testSuppliedParameters() async throws { 27 | let googleParameters = GoogleAPI.GoogleAPIParameters(includeGrantedScopes: true, loginHint: "john@doe.com") 28 | 29 | XCTAssertNotNil(googleParameters.loginHint) 30 | XCTAssertTrue(googleParameters.includeGrantedScopes) 31 | 32 | let callback = URL(string: "callback://google_api") 33 | XCTAssertNotNil(callback) 34 | 35 | let creds = AppCredentials(clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], callbackURL: callback!) 36 | let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) 37 | let config = Authenticator.Configuration( 38 | appCredentials: creds, 39 | tokenHandling: tokenHandling, 40 | userAuthenticator: Authenticator.failingUserAuthenticator 41 | ) 42 | let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } 43 | 44 | // Validate URL is properly constructed 45 | let params = TokenHandling.AuthorizationURLParameters( 46 | credentials: creds, 47 | pcke: nil, 48 | parRequestURI: nil, 49 | stateToken: "unused", 50 | responseProvider: provider 51 | ) 52 | let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) 53 | 54 | let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) 55 | XCTAssertNotNil(urlComponent) 56 | XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) 57 | 58 | // Validate query items inclusion and value 59 | XCTAssertNotNil(urlComponent!.queryItems) 60 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) 61 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) 62 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) 63 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == "john@doe.com" })) 64 | } 65 | 66 | func testDefaultParameters() async throws { 67 | let googleParameters = GoogleAPI.GoogleAPIParameters() 68 | 69 | XCTAssertNil(googleParameters.loginHint) 70 | XCTAssertTrue(googleParameters.includeGrantedScopes) 71 | 72 | let callback = URL(string: "callback://google_api") 73 | XCTAssertNotNil(callback) 74 | 75 | let creds = AppCredentials(clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], callbackURL: callback!) 76 | let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) 77 | let config = Authenticator.Configuration( 78 | appCredentials: creds, 79 | tokenHandling: tokenHandling, 80 | userAuthenticator: Authenticator.failingUserAuthenticator 81 | ) 82 | let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } 83 | 84 | // Validate URL is properly constructed 85 | let params = TokenHandling.AuthorizationURLParameters( 86 | credentials: creds, 87 | pcke: nil, 88 | parRequestURI: nil, 89 | stateToken: "unused", 90 | responseProvider: provider 91 | ) 92 | let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) 93 | 94 | let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) 95 | XCTAssertNotNil(urlComponent) 96 | XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) 97 | 98 | // Validate query items inclusion and value 99 | XCTAssertNotNil(urlComponent!.queryItems) 100 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) 101 | XCTAssertFalse(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) 102 | XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/MastodonTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OAuthenticator 3 | 4 | final class MastodonTests: XCTestCase { 5 | func testAppResponseDecode() throws { 6 | // From https://docs.joinmastodon.org/entities/Token/ 7 | let content = """ 8 | {"access_token": "ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0", "token_type": "bearer", "scope": "read write follow push", "created_at": 1573979017} 9 | """ 10 | let data = try XCTUnwrap(content.data(using: .utf8)) 11 | let response = try JSONDecoder().decode(Mastodon.AppAuthResponse.self, from: data) 12 | 13 | XCTAssertEqual(response.accessToken, "ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0") 14 | 15 | let login = response.login 16 | XCTAssertEqual(login.accessToken.value, "ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/OAuthenticatorTests/PKCETests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | import OAuthenticator 4 | 5 | struct PKCETest { 6 | @Test func customHashFunction() throws { 7 | let pkce = PKCEVerifier(hash: "abc") { input in 8 | "abc" + input 9 | } 10 | 11 | let challenge = pkce.challenge 12 | 13 | #expect(challenge.method == "abc") 14 | #expect(challenge.value == "abc" + pkce.verifier) 15 | } 16 | 17 | #if canImport(CryptoKit) 18 | @Test func defaultHashFunction() throws { 19 | let pkce = PKCEVerifier() 20 | 21 | let challenge = pkce.challenge 22 | 23 | #expect(challenge.method == "S256") 24 | } 25 | #endif 26 | } 27 | --------------------------------------------------------------------------------