├── .github
├── FUNDING.yml
└── workflows
│ └── ci.yml
├── .spi.yml
├── .gitignore
├── .editorconfig
├── Sources
└── OAuthenticator
│ ├── Data+Base64URLEncode.swift
│ ├── PrivacyInfo.xcprivacy
│ ├── WebAuthenticationSession+Utility.swift
│ ├── DPoPKey.swift
│ ├── CredentialWindowProvider.swift
│ ├── URL+QueryParams.swift
│ ├── URLSession+ResponseProvider.swift
│ ├── PKCE.swift
│ ├── ASWebAuthenticationSession+Utility.swift
│ ├── DPoPSigner.swift
│ ├── Services
│ ├── GitHub.swift
│ ├── Mastodon.swift
│ ├── Bluesky.swift
│ └── GoogleAPI.swift
│ ├── WellknownEndpoints.swift
│ ├── Models.swift
│ └── Authenticator.swift
├── Tests
└── OAuthenticatorTests
│ ├── PKCETests.swift
│ ├── GitHubTests.swift
│ ├── MastodonTests.swift
│ ├── DPoPSignerTests.swift
│ ├── BlueskyTests.swift
│ ├── GoogleTests.swift
│ ├── WellKnownTests.swift
│ └── AuthenticatorTests.swift
├── Package.swift
├── LICENSE
├── CODE_OF_CONDUCT.md
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [mattmassicotte]
2 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [OAuthenticator]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyAccessedAPITypes
6 |
7 | NSPrivacyCollectedDataTypes
8 |
9 | NSPrivacyTrackingDomains
10 |
11 | NSPrivacyTracking
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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..) -> 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 |
--------------------------------------------------------------------------------
/Tests/OAuthenticatorTests/BlueskyTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | import OAuthenticator
5 |
6 | struct BlueskyTests {
7 | @Test
8 | func tokenHandling() async throws {
9 | let metadataContent = """
10 | {"issuer":"https://server-metadata.test","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://server-metadata.test/oauth/jwks","authorization_endpoint":"https://server-metadata.test/oauth/authorize","token_endpoint":"https://server-metadata.test/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://server-metadata.test/oauth/revoke","pushed_authorization_request_endpoint":"https://server-metadata.test/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true}
11 | """
12 |
13 | let data = try #require(metadataContent.data(using: .utf8))
14 |
15 | let metadata = try JSONDecoder().decode(ServerMetadata.self, from: data)
16 | let handling = Bluesky.tokenHandling(
17 | account: "placeholder",
18 | server: metadata,
19 | jwtGenerator: { _ in "" }
20 | )
21 |
22 | let provider: URLResponseProvider = { request in
23 | let response = HTTPURLResponse(
24 | url: request.url!,
25 | statusCode: 200,
26 | httpVersion: "1.1",
27 | headerFields: [
28 | "Content-Type": "application/json"
29 | ]
30 | )!
31 |
32 | let payload = """
33 | {"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120}
34 | """
35 |
36 | return (Data(payload.utf8), response)
37 | }
38 |
39 | let verifier = PKCEVerifier()
40 | let params = TokenHandling.LoginProviderParameters(
41 | authorizationURL: URL(string: "https://server-metadata.test/oauth/authorize")!,
42 | credentials: AppCredentials(
43 | clientId: "a",
44 | clientPassword: "b",
45 | scopes: [],
46 | callbackURL: URL(string: "app.test://callback")!,
47 | ),
48 | redirectURL: URL(string: "app.test://callback?code=123&state=state&iss=this_is_incorrect")!,
49 | responseProvider: provider,
50 | stateToken: "state",
51 | pcke: verifier
52 | )
53 |
54 | await #expect(throws: AuthenticatorError.issuingServerMismatch("this_is_incorrect", "https://server-metadata.test")) {
55 | try await handling.loginProvider(params)
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | // See: https://www.rfc-editor.org/rfc/rfc8414.html
11 | public struct ServerMetadata: Codable, Hashable, Sendable {
12 | public let issuer: String
13 | public let authorizationEndpoint: String
14 | public let tokenEndpoint: String
15 | public let responseTypesSupported: [String]
16 | public let grantTypesSupported: [String]
17 | public let codeChallengeMethodsSupported: [String]
18 | public let tokenEndpointAuthMethodsSupported: [String]
19 | public let tokenEndpointAuthSigningAlgValuesSupported: [String]
20 | public let scopesSupported: [String]
21 | public let authorizationResponseIssParameterSupported: Bool
22 | public let requirePushedAuthorizationRequests: Bool
23 | public let pushedAuthorizationRequestEndpoint: String
24 | public let dpopSigningAlgValuesSupported: [String]
25 | public let requireRequestUriRegistration: Bool
26 | public let clientIdMetadataDocumentSupported: Bool
27 |
28 | enum CodingKeys: String, CodingKey {
29 | case issuer
30 | case authorizationEndpoint = "authorization_endpoint"
31 | case tokenEndpoint = "token_endpoint"
32 | case responseTypesSupported = "response_types_supported"
33 | case grantTypesSupported = "grant_types_supported"
34 | case codeChallengeMethodsSupported = "code_challenge_methods_supported"
35 | case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
36 | case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported"
37 | case scopesSupported = "scopes_supported"
38 | case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported"
39 | case requirePushedAuthorizationRequests = "require_pushed_authorization_requests"
40 | case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"
41 | case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported"
42 | case requireRequestUriRegistration = "require_request_uri_registration"
43 | case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported"
44 | }
45 |
46 | public static func load(for host: String, provider: URLResponseProvider) async throws -> ServerMetadata {
47 | var components = URLComponents()
48 |
49 | components.scheme = "https"
50 | components.host = host
51 | components.path = "/.well-known/oauth-authorization-server"
52 |
53 | guard let url = components.url else {
54 | throw MetadataError.urlInvalid
55 | }
56 |
57 | var request = URLRequest(url: url)
58 | request.setValue("application/json", forHTTPHeaderField: "Accept")
59 |
60 | let (data, _) = try await provider(request)
61 |
62 | return try JSONDecoder().decode(ServerMetadata.self, from: data)
63 | }
64 | }
65 |
66 | // See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
67 |
68 | public struct ClientMetadata: Hashable, Codable, Sendable {
69 | public let clientId: String
70 | public let scope: String
71 | public let redirectURIs: [String]
72 | public let dpopBoundAccessTokens: Bool
73 |
74 | enum CodingKeys: String, CodingKey {
75 | case clientId = "client_id"
76 | case scope
77 | case redirectURIs = "redirect_uris"
78 | case dpopBoundAccessTokens = "dpop_bound_access_tokens"
79 | }
80 |
81 | public static func load(for client_id: String, provider: URLResponseProvider) async throws
82 | -> ClientMetadata
83 | {
84 | guard let url = URL(string: client_id) else {
85 | throw MetadataError.urlInvalid
86 | }
87 |
88 | var request = URLRequest(url: url)
89 | request.setValue("application/json", forHTTPHeaderField: "Accept")
90 |
91 | let (data, _) = try await provider(request)
92 |
93 | return try JSONDecoder().decode(ClientMetadata.self, from: data)
94 | }
95 | }
96 |
97 | extension ClientMetadata {
98 | public var credentials: AppCredentials {
99 | let url = redirectURIs.first.map({ URL(string: $0)! })!
100 |
101 | return AppCredentials(
102 | clientId: clientId,
103 | clientPassword: "",
104 | scopes: scope.components(separatedBy: " "),
105 | callbackURL: url
106 | )
107 | }
108 | }
109 |
110 | // See: https://www.rfc-editor.org/rfc/rfc9728.html
111 | public struct ProtectedResourceMetadata: Codable, Hashable, Sendable {
112 | public let resource: String
113 | public let authorizationServers: [String]?
114 | public let jwksUri: String?
115 | public let scopesSupported: [String]?
116 | public let bearerMethodsSupported: [String]?
117 | public let resourceSigningAlgValuesSupported: [String]?
118 | public let resourceName: String?
119 | public let resourceDocumentation: String?
120 | public let resourcePolicyUri: String?
121 | public let resourceTosUri: String?
122 | public let tlsClientCertificateBoundAccessTokens: Bool?
123 | public let authorizationDetailsTypesSupported: [String]?
124 | public let dpopSigningAlgValuesSupported: [String]?
125 | public let dpopBoundAccessTokensRequired: Bool?
126 | public let signedMetadata: String?
127 |
128 | enum CodingKeys: String, CodingKey {
129 | case resource
130 | case authorizationServers = "authorization_servers"
131 | case jwksUri = "jwks_uri"
132 | case scopesSupported = "scopes_supported"
133 | case bearerMethodsSupported = "bearer_methods_supported"
134 | case resourceSigningAlgValuesSupported = "resource_signing_alg_values_supported"
135 | case resourceName = "resource_name"
136 | case resourceDocumentation = "resource_documentation"
137 | case resourcePolicyUri = "resource_policy_uri"
138 | case resourceTosUri = "resource_tos_uri"
139 | case tlsClientCertificateBoundAccessTokens = "tls_client_certificate_bound_access_tokens"
140 | case authorizationDetailsTypesSupported = "authorization_details_types_supported"
141 | case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported"
142 | case dpopBoundAccessTokensRequired = "dpop_bound_access_tokens_required"
143 | case signedMetadata = "signed_metadata"
144 | }
145 |
146 | public static func load(for host: String, provider: URLResponseProvider) async throws
147 | -> ProtectedResourceMetadata
148 | {
149 | var components = URLComponents()
150 | components.scheme = "https"
151 | components.host = host
152 | components.path = "/.well-known/oauth-protected-resource"
153 |
154 | guard let url = components.url else {
155 | throw MetadataError.urlInvalid
156 | }
157 |
158 | var request = URLRequest(url: url)
159 | request.setValue("application/json", forHTTPHeaderField: "Accept")
160 |
161 | let (data, _) = try await provider(request)
162 |
163 | return try self.loadJson(data: data)
164 | }
165 |
166 | public static func loadJson(data: Data) throws -> ProtectedResourceMetadata {
167 | return try JSONDecoder().decode(ProtectedResourceMetadata.self, from: data)
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/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: Sendable {
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 | public init(
136 | authorizationURL: URL,
137 | credentials: AppCredentials,
138 | redirectURL: URL,
139 | responseProvider: @escaping URLResponseProvider,
140 | stateToken: String,
141 | pcke: PKCEVerifier?
142 | ) {
143 | self.authorizationURL = authorizationURL
144 | self.credentials = credentials
145 | self.redirectURL = redirectURL
146 | self.responseProvider = responseProvider
147 | self.stateToken = stateToken
148 | self.pcke = pcke
149 | }
150 | }
151 |
152 | /// The output of this is a URL suitable for user authentication in a browser.
153 | public typealias AuthorizationURLProvider = @Sendable (AuthorizationURLParameters) async throws -> URL
154 |
155 | /// A function that processes the results of an authentication operation
156 | ///
157 | /// URL: The result of the Configuration.UserAuthenticator function
158 | /// AppCredentials: The credentials from Configuration.appCredentials
159 | /// URL: the authenticated URL from the OAuth service
160 | /// URLResponseProvider: the authenticator's provider
161 | public typealias LoginProvider = @Sendable (LoginProviderParameters) async throws -> Login
162 | public typealias RefreshProvider = @Sendable (Login, AppCredentials, URLResponseProvider) async throws -> Login
163 | public typealias ResponseStatusProvider = @Sendable ((Data, URLResponse)) throws -> ResponseStatus
164 |
165 | public let authorizationURLProvider: AuthorizationURLProvider
166 | public let loginProvider: LoginProvider
167 | public let refreshProvider: RefreshProvider?
168 | public let responseStatusProvider: ResponseStatusProvider
169 | public let dpopJWTGenerator: DPoPSigner.JWTGenerator?
170 | public let parConfiguration: PARConfiguration?
171 | public let pkce: PKCEVerifier?
172 |
173 | public init(
174 | parConfiguration: PARConfiguration? = nil,
175 | authorizationURLProvider: @escaping AuthorizationURLProvider,
176 | loginProvider: @escaping LoginProvider,
177 | refreshProvider: RefreshProvider? = nil,
178 | responseStatusProvider: @escaping ResponseStatusProvider = Self.refreshOrAuthorizeWhenUnauthorized,
179 | dpopJWTGenerator: DPoPSigner.JWTGenerator? = nil,
180 | pkce: PKCEVerifier? = nil
181 |
182 | ) {
183 | self.authorizationURLProvider = authorizationURLProvider
184 | self.loginProvider = loginProvider
185 | self.refreshProvider = refreshProvider
186 | self.responseStatusProvider = responseStatusProvider
187 | self.dpopJWTGenerator = dpopJWTGenerator
188 | self.parConfiguration = parConfiguration
189 | self.pkce = pkce
190 | }
191 |
192 | @Sendable
193 | public static func allResponsesValid(result: (Data, URLResponse)) throws -> ResponseStatus {
194 | return .valid
195 | }
196 |
197 | @Sendable
198 | public static func refreshOrAuthorizeWhenUnauthorized(result: (Data, URLResponse)) throws -> ResponseStatus {
199 | guard let response = result.1 as? HTTPURLResponse else {
200 | throw AuthenticatorError.httpResponseExpected
201 | }
202 |
203 | if response.statusCode == 401 {
204 | return .refresh
205 | }
206 |
207 | return .valid
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/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
145 | )
146 |
147 | var request = URLRequest(url: tokenURL)
148 |
149 | request.httpMethod = "POST"
150 | request.setValue("application/json", forHTTPHeaderField: "Content-Type")
151 | request.setValue("application/json", forHTTPHeaderField: "Accept")
152 | request.httpBody = try JSONEncoder().encode(tokenRequest)
153 |
154 | let (data, _) = try await params.responseProvider(request)
155 |
156 | let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data)
157 |
158 | guard tokenResponse.token_type == "DPoP" else {
159 | throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type)
160 | }
161 |
162 | if iss != server.issuer {
163 | throw AuthenticatorError.issuingServerMismatch(iss, server.issuer)
164 | }
165 |
166 | return tokenResponse.login(for: iss)
167 | }
168 | }
169 |
170 | private static func refreshProvider(server: ServerMetadata) -> TokenHandling.RefreshProvider {
171 | { login, credentials, responseProvider -> Login in
172 | guard let refreshToken = login.refreshToken?.value else {
173 | throw AuthenticatorError.refreshNotPossible
174 | }
175 |
176 | guard let tokenURL = URL(string: server.tokenEndpoint) else {
177 | throw AuthenticatorError.missingTokenURL
178 | }
179 |
180 | let tokenRequest = RefreshTokenRequest(
181 | refresh_token: refreshToken,
182 | redirect_uri: credentials.callbackURL.absoluteString,
183 | grant_type: "refresh_token",
184 | client_id: credentials.clientId
185 | )
186 |
187 | var request = URLRequest(url: tokenURL)
188 |
189 | request.httpMethod = "POST"
190 | request.setValue("application/json", forHTTPHeaderField: "Content-Type")
191 | request.httpBody = try JSONEncoder().encode(tokenRequest)
192 |
193 | let (data, response) = try await responseProvider(request)
194 |
195 | // make sure that we got a successful HTTP response
196 | guard
197 | let httpResponse = response as? HTTPURLResponse,
198 | httpResponse.statusCode >= 200 && httpResponse.statusCode < 300
199 | else {
200 | print("data:", String(decoding: data, as: UTF8.self))
201 | print("response:", response)
202 |
203 | throw AuthenticatorError.refreshNotPossible
204 | }
205 |
206 | let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data)
207 |
208 | guard tokenResponse.token_type == "DPoP" else {
209 | throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type)
210 | }
211 |
212 | return tokenResponse.login(for: server.issuer)
213 | }
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/Tests/OAuthenticatorTests/WellKnownTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | @testable import OAuthenticator
4 |
5 | #if canImport(FoundationNetworking)
6 | import FoundationNetworking
7 | #endif
8 |
9 | final class WellKnownTests: XCTestCase {
10 | func testServerMetadataLoad() async throws {
11 | let loadUrlExp = expectation(description: "load url")
12 |
13 | let mockLoader: URLResponseProvider = { request in
14 | XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
15 | loadUrlExp.fulfill()
16 |
17 | // This is a more minimal Authorization Server Metadata that should be valid,
18 | // but throws an error due to: https://github.com/ChimeHQ/OAuthenticator/issues/37
19 | //
20 | // let content = """
21 | // {"issuer": "https://server-metadata.test", "authorization_endpoint": "https://server-metadata.test/oauth/authorize", "token_endpoint": "https://server-metadata.test/oauth/token"}
22 | // """
23 |
24 | // Response from https://bsky.social/.well-known/oauth-authorization-server
25 | let content = """
26 | {"issuer":"https://server-metadata.test","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://server-metadata.test/oauth/jwks","authorization_endpoint":"https://server-metadata.test/oauth/authorize","token_endpoint":"https://server-metadata.test/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://server-metadata.test/oauth/revoke","pushed_authorization_request_endpoint":"https://server-metadata.test/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true}
27 | """
28 |
29 | let data = try XCTUnwrap(content.data(using: .utf8))
30 |
31 | return (
32 | data,
33 | URLResponse(
34 | url: request.url!,
35 | mimeType: "application/json",
36 | expectedContentLength: data.count,
37 | textEncodingName: "utf-8"
38 | )
39 | )
40 | }
41 |
42 | let url = URL(string: "https://server-metadata.test/")!
43 | let response = try await ServerMetadata.load(
44 | for: url.host!,
45 | provider: mockLoader
46 | )
47 |
48 | await fulfillment(of: [loadUrlExp], timeout: 1.0, enforceOrder: true)
49 |
50 | XCTAssertEqual(response.issuer, "https://server-metadata.test")
51 | XCTAssertEqual(response.authorizationEndpoint, "https://server-metadata.test/oauth/authorize")
52 | XCTAssertEqual(response.tokenEndpoint, "https://server-metadata.test/oauth/token")
53 | }
54 |
55 | func testClientMetadataLoad() async throws {
56 | let loadUrlExp = expectation(description: "load url")
57 |
58 | let mockLoader: URLResponseProvider = { request in
59 | XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
60 | loadUrlExp.fulfill()
61 |
62 | let content = """
63 | {"client_id": "https://client-metadata.test/oauth-client-metadata.json", "scope": "atproto", "redirect_uris": ["https://client-metadata.test/oauth/callback"], "dpop_bound_access_tokens": true}
64 | """
65 |
66 | let data = try XCTUnwrap(content.data(using: .utf8))
67 |
68 | return (
69 | data,
70 | URLResponse(
71 | url: request.url!,
72 | mimeType: "application/json",
73 | expectedContentLength: data.count,
74 | textEncodingName: "utf-8"
75 | )
76 | )
77 | }
78 |
79 | let url = URL(string: "https://client-metadata.test/oauth-client-metadata.json")!
80 | let response = try await ClientMetadata.load(
81 | for: url.absoluteString,
82 | provider: mockLoader
83 | )
84 |
85 | await fulfillment(of: [loadUrlExp], timeout: 1.0, enforceOrder: true)
86 |
87 | XCTAssertEqual(response.clientId, "https://client-metadata.test/oauth-client-metadata.json")
88 | XCTAssertEqual(response.scope, "atproto")
89 | XCTAssertEqual(response.redirectURIs.isEmpty, false)
90 | XCTAssertEqual(response.redirectURIs.first, "https://client-metadata.test/oauth/callback")
91 | }
92 |
93 | func testProtectedResourceMetadataLoad() async throws {
94 | let loadUrlExp = expectation(description: "load url")
95 |
96 | let mockLoader: URLResponseProvider = { request in
97 | XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
98 | loadUrlExp.fulfill()
99 |
100 | let content = """
101 | {"resource": "https://protected-resource-metadata.test"}
102 | """
103 |
104 | let data = try XCTUnwrap(content.data(using: .utf8))
105 |
106 | return (
107 | data,
108 | URLResponse(
109 | url: request.url!,
110 | mimeType: "application/json",
111 | expectedContentLength: data.count,
112 | textEncodingName: "utf-8"
113 | )
114 | )
115 | }
116 |
117 | let url = URL(string: "https://protected-resource-metadata.test/")!
118 | let response = try await ProtectedResourceMetadata.load(
119 | for: url.host!,
120 | provider: mockLoader
121 | )
122 |
123 | await fulfillment(of: [loadUrlExp], timeout: 1.0, enforceOrder: true)
124 |
125 | XCTAssertEqual(response.resource, "https://protected-resource-metadata.test")
126 | }
127 |
128 | func testProtectedResourceMetadataDecode() throws {
129 | // Response from: https://puffball.us-east.host.bsky.network/.well-known/oauth-protected-resource/
130 | let content = """
131 | {"resource":"https://puffball.us-east.host.bsky.network","authorization_servers":["https://bsky.social"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}
132 | """
133 | let data = try XCTUnwrap(content.data(using: .utf8))
134 | let response = try XCTUnwrap(ProtectedResourceMetadata.loadJson(data: data))
135 |
136 | XCTAssertEqual(response.resource, "https://puffball.us-east.host.bsky.network")
137 | let authorizationServers = try XCTUnwrap(response.authorizationServers)
138 | XCTAssertEqual(authorizationServers.isEmpty, false)
139 | XCTAssertEqual(authorizationServers.first, "https://bsky.social")
140 |
141 | let scopesSupported = try XCTUnwrap(response.scopesSupported)
142 | XCTAssertEqual(scopesSupported.isEmpty, true)
143 |
144 | let bearerMethodsSupported = try XCTUnwrap(response.bearerMethodsSupported)
145 | XCTAssertEqual(bearerMethodsSupported.isEmpty, false)
146 | XCTAssertEqual(bearerMethodsSupported.first, "header")
147 |
148 | XCTAssertEqual(response.resourceDocumentation, "https://atproto.com")
149 |
150 | // Missing fields
151 | XCTAssertNil(response.authorizationDetailsTypesSupported)
152 | XCTAssertNil(response.jwksUri)
153 | XCTAssertNil(response.dpopBoundAccessTokensRequired)
154 | XCTAssertNil(response.dpopSigningAlgValuesSupported)
155 | XCTAssertNil(response.resourceName)
156 | XCTAssertNil(response.resourcePolicyUri)
157 | XCTAssertNil(response.resourceTosUri)
158 | XCTAssertNil(response.signedMetadata)
159 | XCTAssertNil(response.tlsClientCertificateBoundAccessTokens)
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/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/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 issuingServerMismatch(String, String)
28 | case pkceRequired
29 | }
30 |
31 | /// Manage state required to executed authenticated URLRequests.
32 | public actor Authenticator {
33 | public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL
34 | public typealias AuthenticationStatusHandler = @Sendable (Result) async -> Void
35 |
36 | /// A `UserAuthenticator` that always fails. Useful as a placeholder
37 | /// for testing and for doing manual authentication with an external
38 | /// instance not available at configuration-creation time.
39 | @Sendable
40 | public static func failingUserAuthenticator(_ url: URL, _ user: String) throws -> URL {
41 | throw AuthenticatorError.failingAuthenticatorUsed
42 | }
43 |
44 | public enum UserAuthenticationMode: Hashable, Sendable {
45 | /// User authentication will be triggered on-demand.
46 | case automatic
47 |
48 | /// User authentication will only occur via an explicit call to `authenticate()`.
49 | ///
50 | /// 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.
51 | case manualOnly
52 | }
53 |
54 | struct PARResponse: Codable, Hashable, Sendable {
55 | public let requestURI: String
56 | public let expiresIn: Int
57 |
58 | enum CodingKeys: String, CodingKey {
59 | case requestURI = "request_uri"
60 | case expiresIn = "expires_in"
61 | }
62 |
63 | var expiry: Date {
64 | Date(timeIntervalSinceNow: Double(expiresIn))
65 | }
66 | }
67 |
68 | public struct Configuration {
69 | public let appCredentials: AppCredentials
70 |
71 | public let loginStorage: LoginStorage?
72 | public let tokenHandling: TokenHandling
73 | public let userAuthenticator: UserAuthenticator
74 | public let mode: UserAuthenticationMode
75 |
76 | // Specify an authenticationResult closure to obtain result and grantedScope
77 | public let authenticationStatusHandler: AuthenticationStatusHandler?
78 |
79 | #if canImport(AuthenticationServices)
80 | @available(tvOS 16.0, macCatalyst 13.0, *)
81 | public init(
82 | appCredentials: AppCredentials,
83 | loginStorage: LoginStorage? = nil,
84 | tokenHandling: TokenHandling,
85 | mode: UserAuthenticationMode = .automatic,
86 | authenticationStatusHandler: AuthenticationStatusHandler? = nil
87 | ) {
88 | self.appCredentials = appCredentials
89 | self.loginStorage = loginStorage
90 | self.tokenHandling = tokenHandling
91 | self.mode = mode
92 |
93 | // It *should* be possible to use just a reference to
94 | // ASWebAuthenticationSession.userAuthenticator directly here
95 | // with GlobalActorIsolatedTypesUsability, but it isn't working
96 | self.userAuthenticator = { try await ASWebAuthenticationSession.userAuthenticator(url: $0, scheme: $1) }
97 | self.authenticationStatusHandler = authenticationStatusHandler
98 | }
99 | #endif
100 |
101 | public init(
102 | appCredentials: AppCredentials,
103 | loginStorage: LoginStorage? = nil,
104 | tokenHandling: TokenHandling,
105 | mode: UserAuthenticationMode = .automatic,
106 | userAuthenticator: @escaping UserAuthenticator,
107 | authenticationStatusHandler: AuthenticationStatusHandler? = nil
108 | ) {
109 | self.appCredentials = appCredentials
110 | self.loginStorage = loginStorage
111 | self.tokenHandling = tokenHandling
112 | self.mode = mode
113 | self.userAuthenticator = userAuthenticator
114 | self.authenticationStatusHandler = authenticationStatusHandler
115 | }
116 | }
117 |
118 | let config: Configuration
119 |
120 | let urlLoader: URLResponseProvider
121 | private var activeTokenTask: Task?
122 | private var localLogin: Login?
123 | private var dpop = DPoPSigner()
124 | private let stateToken = UUID().uuidString
125 |
126 | public init(config: Configuration, urlLoader loader: URLResponseProvider? = nil) {
127 | self.config = config
128 |
129 | self.urlLoader = loader ?? URLSession.defaultProvider
130 | }
131 |
132 | /// A default `URLSession`-backed `URLResponseProvider`.
133 | @available(*, deprecated, message: "Please move to URLSession.defaultProvider")
134 | @MainActor
135 | public static let defaultResponseProvider: URLResponseProvider = {
136 | let session = URLSession(configuration: .default)
137 |
138 | return session.responseProvider
139 | }()
140 |
141 | /// Add authentication for `request`, execute it, and return its result.
142 | public func response(for request: URLRequest) async throws -> (Data, URLResponse) {
143 | let userAuthenticator = config.userAuthenticator
144 |
145 | let login = try await loginTaskResult(manual: false, userAuthenticator: userAuthenticator)
146 |
147 | let result = try await authedResponse(for: request, login: login)
148 |
149 | let action = try config.tokenHandling.responseStatusProvider(result)
150 |
151 | switch action {
152 | case .authorize:
153 | let newLogin = try await loginFromTask(task: Task {
154 | return try await performUserAuthentication(manual: false, userAuthenticator: userAuthenticator)
155 | })
156 |
157 | return try await authedResponse(for: request, login: newLogin)
158 | case .refresh:
159 | let newLogin = try await loginFromTask(task: Task {
160 | guard let value = try await refresh(with: login) else {
161 | throw AuthenticatorError.unauthorizedRefreshFailed
162 | }
163 |
164 | return value
165 | })
166 |
167 | return try await authedResponse(for: request, login: newLogin)
168 | case .refreshOrAuthorize:
169 | let newLogin = try await loginFromTask(task: Task {
170 | if let value = try await refresh(with: login) {
171 | return value
172 | }
173 |
174 | return try await performUserAuthentication(manual: false, userAuthenticator: userAuthenticator)
175 | })
176 |
177 | return try await authedResponse(for: request, login: newLogin)
178 | case .valid:
179 | return result
180 | }
181 | }
182 |
183 | private func authedResponse(for request: URLRequest, login: Login) async throws -> (Data, URLResponse) {
184 | var authedRequest = request
185 | let token = login.accessToken.value
186 |
187 | if config.tokenHandling.dpopJWTGenerator == nil {
188 | authedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
189 | }
190 |
191 | return try await dpopResponse(for: authedRequest, login: login)
192 | }
193 |
194 | /// Manually perform user authentication, if required.
195 | public func authenticate(with userAuthenticator: UserAuthenticator? = nil) async throws {
196 | let _ = try await loginTaskResult(manual: true, userAuthenticator: userAuthenticator ?? config.userAuthenticator)
197 | }
198 | }
199 |
200 | extension Authenticator {
201 | private func retrieveLogin() async throws -> Login? {
202 | guard let storage = config.loginStorage else {
203 | return localLogin
204 | }
205 |
206 | return try await storage.retrieveLogin()
207 | }
208 |
209 | private func storeLogin(_ login: Login) async throws {
210 | guard let storage = config.loginStorage else {
211 | self.localLogin = login
212 | return
213 | }
214 |
215 | try await storage.storeLogin(login)
216 | }
217 |
218 | private func clearLogin() async {
219 | guard let storage = config.loginStorage else { return }
220 |
221 | let invalidLogin = Login(token: "invalid", validUntilDate: .distantPast)
222 |
223 | do {
224 | try await storage.storeLogin(invalidLogin)
225 | } catch {
226 | print("failed to store an invalid login, possibly stuck", error)
227 | }
228 | }
229 | }
230 |
231 | extension Authenticator {
232 | private func makeLoginTask(manual: Bool, userAuthenticator: @escaping UserAuthenticator) -> Task {
233 | return Task {
234 | guard let login = try await retrieveLogin() else {
235 | return try await performUserAuthentication(manual: manual, userAuthenticator: userAuthenticator)
236 | }
237 |
238 | if login.accessToken.valid {
239 | return login
240 | }
241 |
242 | if let refreshedLogin = try await refresh(with: login) {
243 | return refreshedLogin
244 | }
245 |
246 | return try await performUserAuthentication(manual: manual, userAuthenticator: userAuthenticator)
247 | }
248 | }
249 |
250 | private func loginTaskResult(manual: Bool, userAuthenticator: @escaping UserAuthenticator) async throws -> Login {
251 | let task = activeTokenTask ?? makeLoginTask(manual: manual, userAuthenticator: userAuthenticator)
252 |
253 | var login: Login
254 | do {
255 | do {
256 | login = try await loginFromTask(task: task)
257 | } catch AuthenticatorError.tokenInvalid {
258 | let newTask = makeLoginTask(manual: manual, userAuthenticator: userAuthenticator)
259 | login = try await loginFromTask(task: newTask)
260 | }
261 |
262 | // Inform authenticationResult closure of new login information
263 | await self.config.authenticationStatusHandler?(.success(login))
264 | }
265 | catch let authenticatorError as AuthenticatorError {
266 | await self.config.authenticationStatusHandler?(.failure(authenticatorError))
267 |
268 | // Rethrow error
269 | throw authenticatorError
270 | }
271 |
272 | return login
273 | }
274 |
275 | private func loginFromTask(task: Task) async throws -> Login {
276 | self.activeTokenTask = task
277 |
278 | let login: Login
279 |
280 | do {
281 | login = try await task.value
282 | } catch {
283 | // clear this value on error, but only if has not changed
284 | if task == self.activeTokenTask {
285 | self.activeTokenTask = nil
286 | }
287 |
288 | throw error
289 | }
290 |
291 | guard login.accessToken.valid else {
292 | throw AuthenticatorError.tokenInvalid
293 | }
294 |
295 | return login
296 | }
297 |
298 | private func performUserAuthentication(manual: Bool, userAuthenticator: UserAuthenticator) async throws -> Login {
299 | if manual == false && config.mode == .manualOnly {
300 | throw AuthenticatorError.manualAuthenticationRequired
301 | }
302 |
303 | let parRequestURI = try await getPARRequestURI()
304 |
305 | let authConfig = TokenHandling.AuthorizationURLParameters(
306 | credentials: config.appCredentials,
307 | pcke: config.tokenHandling.pkce,
308 | parRequestURI: parRequestURI,
309 | stateToken: stateToken,
310 | responseProvider: { try await self.dpopResponse(for: $0, login: nil) }
311 | )
312 |
313 | let tokenURL = try await config.tokenHandling.authorizationURLProvider(authConfig)
314 |
315 | let scheme = try config.appCredentials.callbackURLScheme
316 |
317 | let callbackURL = try await userAuthenticator(tokenURL, scheme)
318 |
319 | let params = TokenHandling.LoginProviderParameters(
320 | authorizationURL: tokenURL,
321 | credentials: config.appCredentials,
322 | redirectURL: callbackURL,
323 | responseProvider: { try await self.dpopResponse(for: $0, login: nil) },
324 | stateToken: stateToken,
325 | pcke: config.tokenHandling.pkce
326 | )
327 |
328 | let login = try await config.tokenHandling.loginProvider(params)
329 |
330 | try await storeLogin(login)
331 |
332 | return login
333 | }
334 |
335 | private func refresh(with login: Login) async throws -> Login? {
336 | guard let refreshProvider = config.tokenHandling.refreshProvider else {
337 | return nil
338 | }
339 |
340 | guard let refreshToken = login.refreshToken else {
341 | return nil
342 | }
343 |
344 | guard refreshToken.valid else {
345 | return nil
346 | }
347 |
348 | do {
349 | let login = try await refreshProvider(login, config.appCredentials, { try await self.dpopResponse(for: $0, login: nil) })
350 |
351 | try await storeLogin(login)
352 |
353 | return login
354 | } catch {
355 | await clearLogin()
356 |
357 | throw error
358 | }
359 | }
360 |
361 | private func parRequest(url: URL, params: [String: String]) async throws -> PARResponse {
362 | guard let pkce = config.tokenHandling.pkce else {
363 | throw AuthenticatorError.pkceRequired
364 | }
365 |
366 | let challenge = pkce.challenge
367 | let scopes = config.appCredentials.scopes.joined(separator: " ")
368 | let callbackURI = config.appCredentials.callbackURL
369 | let clientId = config.appCredentials.clientId
370 |
371 | var request = URLRequest(url: url)
372 | request.httpMethod = "POST"
373 | request.setValue("application/json", forHTTPHeaderField: "Accept")
374 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
375 |
376 | let base: [String: String] = [
377 | "client_id": clientId,
378 | "state": stateToken,
379 | "scope": scopes,
380 | "response_type": "code",
381 | "redirect_uri": callbackURI.absoluteString,
382 | "code_challenge": challenge.value,
383 | "code_challenge_method": challenge.method,
384 | ]
385 |
386 | let body = params
387 | .merging(base, uniquingKeysWith: { a, b in a })
388 | .map({ [$0, $1].joined(separator: "=") })
389 | .joined(separator: "&")
390 |
391 | request.httpBody = Data(body.utf8)
392 |
393 | let (parData, _) = try await dpopResponse(for: request, login: nil)
394 |
395 | return try JSONDecoder().decode(PARResponse.self, from: parData)
396 | }
397 |
398 | private func getPARRequestURI() async throws -> String? {
399 | guard let parConfig = config.tokenHandling.parConfiguration else {
400 | return nil
401 | }
402 |
403 | let parResponse = try await parRequest(url: parConfig.url, params: parConfig.parameters)
404 |
405 | return parResponse.requestURI
406 | }
407 | }
408 |
409 | extension Authenticator {
410 | public nonisolated var responseProvider: URLResponseProvider {
411 | { try await self.response(for: $0) }
412 | }
413 |
414 | private func dpopResponse(for request: URLRequest, login: Login?) async throws -> (Data, URLResponse) {
415 | guard let generator = config.tokenHandling.dpopJWTGenerator else {
416 | return try await urlLoader(request)
417 | }
418 |
419 | guard let pkce = config.tokenHandling.pkce else {
420 | throw AuthenticatorError.pkceRequired
421 | }
422 |
423 | let token = login?.accessToken.value
424 | let tokenHash = token.map { pkce.hashFunction($0) }
425 |
426 | return try await self.dpop.response(
427 | isolation: self,
428 | for: request,
429 | using: generator,
430 | token: token,
431 | tokenHash: tokenHash,
432 | issuingServer: login?.issuingServer,
433 | provider: urlLoader
434 | )
435 | }
436 | }
437 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | @MainActor
277 | func testManualAuthenticationWithSuccessResult() async throws {
278 | let urlProvider: TokenHandling.AuthorizationURLProvider = { params in
279 | return URL(string: "my://auth?client_id=\(params.credentials.clientId)")!
280 | }
281 |
282 | let loginProvider: TokenHandling.LoginProvider = { params in
283 | XCTAssertEqual(params.redirectURL, URL(string: "my://login")!)
284 |
285 | return Login(token: "TOKEN")
286 | }
287 |
288 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider,
289 | loginProvider: loginProvider,
290 | responseStatusProvider: TokenHandling.allResponsesValid)
291 |
292 | let userAuthExp = expectation(description: "user auth")
293 | let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in
294 | userAuthExp.fulfill()
295 |
296 | return URL(string: "my://login")!
297 | }
298 |
299 | // This is the callback to obtain authentication results
300 | var authenticatedLogin: Login?
301 | let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in
302 | switch result {
303 | case .failure(_):
304 | XCTFail()
305 | case .success(let login):
306 | authenticatedLogin = login
307 | }
308 | }
309 |
310 | // Configure Authenticator with result callback
311 | let config = Authenticator.Configuration(
312 | appCredentials: Self.mockCredentials,
313 | tokenHandling: tokenHandling,
314 | mode: .manualOnly,
315 | userAuthenticator: mockUserAuthenticator,
316 | authenticationStatusHandler: authenticationCallback
317 | )
318 |
319 | let loadExp = expectation(description: "load url")
320 | let mockLoader: URLResponseProvider = { request in
321 | loadExp.fulfill()
322 |
323 | return MockURLResponseProvider.dummyResponse
324 | }
325 |
326 | let auth = Authenticator(config: config, urlLoader: mockLoader)
327 | // Explicitly authenticate and grab Login information after
328 | try await auth.authenticate()
329 |
330 | // Ensure our authenticatedLogin objet is available and contains the proper Token
331 | XCTAssertNotNil(authenticatedLogin)
332 | XCTAssertEqual(authenticatedLogin!, Login(token:"TOKEN"))
333 |
334 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!))
335 |
336 | await fulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true)
337 | }
338 |
339 | // Test AuthenticationResultHandler with a failed UserAuthenticator
340 | @MainActor
341 | func testManualAuthenticationWithFailedResult() async throws {
342 | let urlProvider: TokenHandling.AuthorizationURLProvider = { params in
343 | return URL(string: "my://auth?client_id=\(params.credentials.clientId)")!
344 | }
345 |
346 | let loginProvider: TokenHandling.LoginProvider = { params in
347 | XCTAssertEqual(params.redirectURL, URL(string: "my://login")!)
348 |
349 | return Login(token: "TOKEN")
350 | }
351 |
352 | let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider,
353 | loginProvider: loginProvider,
354 | responseStatusProvider: TokenHandling.allResponsesValid)
355 |
356 | // This is the callback to obtain authentication results
357 | var authenticatedLogin: Login?
358 | let failureAuth = expectation(description: "auth failure")
359 | let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in
360 | switch result {
361 | case .failure(_):
362 | failureAuth.fulfill()
363 | authenticatedLogin = nil
364 | case .success(_):
365 | XCTFail()
366 | }
367 | }
368 |
369 | // Configure Authenticator with result callback
370 | let config = Authenticator.Configuration(
371 | appCredentials: Self.mockCredentials,
372 | tokenHandling: tokenHandling,
373 | mode: .manualOnly,
374 | userAuthenticator: Authenticator.failingUserAuthenticator,
375 | authenticationStatusHandler: authenticationCallback
376 | )
377 |
378 | let auth = Authenticator(config: config, urlLoader: nil)
379 | do {
380 | // Explicitly authenticate and grab Login information after
381 | try await auth.authenticate()
382 |
383 | // Ensure our authenticatedLogin objet is *not* available
384 | XCTAssertNil(authenticatedLogin)
385 | }
386 | catch let error as AuthenticatorError {
387 | XCTAssertEqual(error, AuthenticatorError.failingAuthenticatorUsed)
388 | }
389 | catch {
390 | throw error
391 | }
392 |
393 | await fulfillment(of: [failureAuth], timeout: 1.0, enforceOrder: true)
394 | }
395 |
396 | func testUnauthorizedRequestRefreshes() async throws {
397 | let requestedURL = URL(string: "https://example.com")!
398 |
399 | let mockLoader = MockURLResponseProvider()
400 | let mockData = "hello".data(using: .utf8)!
401 |
402 | mockLoader.responses = [
403 | .success((Data(), HTTPURLResponse(url: requestedURL, statusCode: 401, httpVersion: nil, headerFields: nil)!)),
404 | .success((mockData, HTTPURLResponse(url: requestedURL, statusCode: 200, httpVersion: nil, headerFields: nil)!)),
405 | ]
406 |
407 | let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in
408 | return Login(token: "REFRESHED")
409 | }
410 |
411 | let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider,
412 | loginProvider: Self.disabledLoginProvider,
413 | refreshProvider: refreshProvider)
414 |
415 | let storage = LoginStorage {
416 | // ensure we actually try this one
417 | return Login(accessToken: Token(value: "EXPIRED", expiry: .distantFuture),
418 | refreshToken: Token(value: "REFRESH"))
419 | } storeLogin: { login in
420 | XCTAssertEqual(login.accessToken.value, "REFRESHED")
421 | }
422 |
423 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
424 | loginStorage: storage,
425 | tokenHandling: tokenHandling,
426 | userAuthenticator: Self.disabledUserAuthenticator)
427 |
428 | let auth = Authenticator(config: config, urlLoader: mockLoader.responseProvider)
429 |
430 | let (data, _) = try await auth.response(for: URLRequest(url: requestedURL))
431 |
432 | XCTAssertEqual(data, mockData)
433 | XCTAssertEqual(mockLoader.requests.count, 2)
434 | XCTAssertEqual(mockLoader.requests[0].allHTTPHeaderFields!["Authorization"], "Bearer EXPIRED")
435 | XCTAssertEqual(mockLoader.requests[1].allHTTPHeaderFields!["Authorization"], "Bearer REFRESHED")
436 | }
437 |
438 | actor RequestContainer {
439 | private(set) var sentRequests: [URLRequest] = []
440 |
441 | func addRequest(_ request: URLRequest) {
442 | self.sentRequests.append(request)
443 | }
444 | }
445 |
446 | @available(macOS 13.0, macCatalyst 16.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
447 | @MainActor
448 | func testTokenExpiredAfterUseRefresh() async throws {
449 | var sentRequests: [URLRequest] = []
450 |
451 | let mockLoader: URLResponseProvider = { @MainActor request in
452 | sentRequests.append(request)
453 | return MockURLResponseProvider.dummyResponse
454 | }
455 |
456 | var refreshedLogins: [Login] = []
457 | let refreshProvider: TokenHandling.RefreshProvider = { @MainActor login, _, _ in
458 | refreshedLogins.append(login)
459 |
460 | return Login(token: "REFRESHED")
461 | }
462 |
463 | let tokenHandling = TokenHandling(
464 | authorizationURLProvider: Self.disabledAuthorizationURLProvider,
465 | loginProvider: Self.disabledLoginProvider,
466 | refreshProvider: refreshProvider,
467 | responseStatusProvider: TokenHandling.allResponsesValid
468 | )
469 |
470 | let storedLogin = Login(
471 | accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(1)),
472 | refreshToken: Token(value: "REFRESH")
473 | )
474 | var loadLoginCount = 0
475 | var savedLogins: [Login] = []
476 | let storage = LoginStorage { @MainActor in
477 | loadLoginCount += 1
478 |
479 | return storedLogin
480 | } storeLogin: { @MainActor login in
481 | savedLogins.append(login)
482 | }
483 |
484 | let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
485 | loginStorage: storage,
486 | tokenHandling: tokenHandling,
487 | userAuthenticator: Self.disabledUserAuthenticator)
488 |
489 | let auth = Authenticator(config: config, urlLoader: mockLoader)
490 |
491 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!))
492 | let sentRequestsOne = sentRequests
493 |
494 | XCTAssertEqual(sentRequestsOne.count, 1, "First request should be sent")
495 | XCTAssertEqual(sentRequestsOne.first?.value(forHTTPHeaderField: "Authorization"), "Bearer EXPIRE SOON", "Non expired token should be used for first request")
496 | XCTAssertTrue(refreshedLogins.isEmpty, "Token should not be refreshed after first request")
497 | XCTAssertEqual(loadLoginCount, 1, "Login should be loaded from storage once")
498 | XCTAssertTrue(savedLogins.isEmpty, "Login storage should not be updated after first request")
499 |
500 | // Let the token expire
501 | try await Task.sleep(for: .seconds(1))
502 |
503 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!))
504 | let sentRequestsTwo = sentRequests
505 |
506 | XCTAssertEqual(refreshedLogins.count, 1, "Token should be refreshed")
507 | XCTAssertEqual(refreshedLogins.first?.accessToken.value, "EXPIRE SOON", "Expired token should be passed to refresh call")
508 | XCTAssertEqual(refreshedLogins.first?.refreshToken?.value, "REFRESH", "Refresh token should be passed to refresh call")
509 | XCTAssertEqual(loadLoginCount, 2, "New login should be loaded from storage")
510 | XCTAssertEqual(sentRequestsTwo.count, 2, "Second request should be sent")
511 | let secondRequest = sentRequestsTwo.dropFirst().first
512 | XCTAssertEqual(secondRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for second request")
513 | XCTAssertEqual(savedLogins.first?.accessToken.value, "REFRESHED", "Refreshed token should be saved to storage")
514 |
515 | let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!))
516 | let sentRequestsThree = sentRequests
517 |
518 | XCTAssertEqual(refreshedLogins.count, 1, "No additional refreshes should happen")
519 | XCTAssertEqual(loadLoginCount, 2, "No additional login loads should happen")
520 | XCTAssertEqual(sentRequestsThree.count, 3, "Third request should be sent")
521 | let thirdRequest = sentRequestsThree.dropFirst(2).first
522 | XCTAssertEqual(thirdRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for third request")
523 | XCTAssertEqual(savedLogins.count, 1, "No additional logins should be saved to storage")
524 | }
525 | }
526 |
--------------------------------------------------------------------------------