ATProto for TCA applications in Swift
0

Configure Feed

Select the types of activity you want to include in your feed.

Get tests compiling on android

Woodrow Melling (Apr 21, 2026, 10:52 AM -0600) 24fd9db0 baffe2ae

+1431 -235
+59
.swiftpm/xcode/xcshareddata/xcschemes/ATProtoOAuthTests 1.xcscheme
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Scheme 3 + LastUpgradeVersion = "2640" 4 + version = "1.7"> 5 + <BuildAction 6 + parallelizeBuildables = "YES" 7 + buildImplicitDependencies = "YES" 8 + buildArchitectures = "Automatic"> 9 + </BuildAction> 10 + <TestAction 11 + buildConfiguration = "Debug" 12 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 13 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 14 + shouldUseLaunchSchemeArgsEnv = "YES"> 15 + <TestPlans> 16 + <TestPlanReference 17 + reference = "container:../../TCA26Tests.xctestplan"> 18 + </TestPlanReference> 19 + </TestPlans> 20 + <Testables> 21 + <TestableReference 22 + skipped = "NO"> 23 + <BuildableReference 24 + BuildableIdentifier = "primary" 25 + BlueprintIdentifier = "ATProtoOAuthTests" 26 + BuildableName = "ATProtoOAuthTests" 27 + BlueprintName = "ATProtoOAuthTests" 28 + ReferencedContainer = "container:"> 29 + </BuildableReference> 30 + </TestableReference> 31 + </Testables> 32 + </TestAction> 33 + <LaunchAction 34 + buildConfiguration = "Debug" 35 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 36 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 37 + launchStyle = "0" 38 + useCustomWorkingDirectory = "NO" 39 + ignoresPersistentStateOnLaunch = "NO" 40 + debugDocumentVersioning = "YES" 41 + debugServiceExtension = "internal" 42 + allowLocationSimulation = "YES" 43 + queueDebuggingEnableBacktraceRecording = "Yes"> 44 + </LaunchAction> 45 + <ProfileAction 46 + buildConfiguration = "Release" 47 + shouldUseLaunchSchemeArgsEnv = "YES" 48 + savedToolIdentifier = "" 49 + useCustomWorkingDirectory = "NO" 50 + debugDocumentVersioning = "YES"> 51 + </ProfileAction> 52 + <AnalyzeAction 53 + buildConfiguration = "Debug"> 54 + </AnalyzeAction> 55 + <ArchiveAction 56 + buildConfiguration = "Release" 57 + revealArchiveInOrganizer = "YES"> 58 + </ArchiveAction> 59 + </Scheme>
+29 -11
Package.resolved
··· 1 1 { 2 - "originHash" : "971312876e09d1a88e416bc433e49003860302151a508a615396a0f7c7b5fcbb", 2 + "originHash" : "204e85b419ac3a91768328064a71ec7c1635fc29d1b2ddbd4f6a454bcc89a9bb", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "combine-schedulers", 6 6 "kind" : "remoteSourceControl", 7 7 "location" : "https://github.com/pointfreeco/combine-schedulers", 8 8 "state" : { 9 - "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", 10 - "version" : "1.1.0" 9 + "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", 10 + "version" : "1.2.0" 11 11 } 12 12 }, 13 13 { ··· 17 17 "state" : { 18 18 "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", 19 19 "version" : "1.5.1" 20 + } 21 + }, 22 + { 23 + "identity" : "swift-asn1", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/apple/swift-asn1.git", 26 + "state" : { 27 + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", 28 + "version" : "1.7.0" 20 29 } 21 30 }, 22 31 { ··· 74 83 } 75 84 }, 76 85 { 86 + "identity" : "swift-crypto", 87 + "kind" : "remoteSourceControl", 88 + "location" : "https://github.com/apple/swift-crypto", 89 + "state" : { 90 + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", 91 + "version" : "3.15.1" 92 + } 93 + }, 94 + { 77 95 "identity" : "swift-custom-dump", 78 96 "kind" : "remoteSourceControl", 79 97 "location" : "https://github.com/pointfreeco/swift-custom-dump", ··· 97 115 "location" : "https://github.com/pointfreeco/swift-dependencies", 98 116 "state" : { 99 117 "branch" : "26", 100 - "revision" : "6f4e48611cf20f0ae7255265890cee6271cc89f3" 118 + "revision" : "929e0e2a83032fa50119457ae2963ac6eb313e69" 101 119 } 102 120 }, 103 121 { ··· 106 124 "location" : "https://tangled.org/woody.fm/swift-dependencies-http-client", 107 125 "state" : { 108 126 "branch" : "main", 109 - "revision" : "8f9ea05ad21473973a7e5f50636f0840258556ae" 127 + "revision" : "0314369b46fcc628e0125c218fff4f54c46c2e82" 110 128 } 111 129 }, 112 130 { ··· 182 200 } 183 201 }, 184 202 { 185 - "identity" : "swift-urlqueryitem-coder", 203 + "identity" : "swift-tagged-types-macro", 186 204 "kind" : "remoteSourceControl", 187 - "location" : "https://tangled.org/woody.fm/swift-urlqueryitem-coder", 205 + "location" : "https://tangled.org/woody.fm/swift-tagged-types-macro", 188 206 "state" : { 189 207 "branch" : "main", 190 - "revision" : "6b9506fd46d80898a92a5afd68009e39faa173a0" 208 + "revision" : "3152e90cec2a8a6c64381d19fbd7d0ece85620fc" 191 209 } 192 210 }, 193 211 { 194 - "identity" : "tca26", 212 + "identity" : "swift-urlqueryitem-coder", 195 213 "kind" : "remoteSourceControl", 196 - "location" : "https://github.com/pointfreeco/TCA26", 214 + "location" : "https://tangled.org/woody.fm/swift-urlqueryitem-coder", 197 215 "state" : { 198 216 "branch" : "main", 199 - "revision" : "43bf5315c30477e9b800aa7859dbeda5079f402b" 217 + "revision" : "ad713107ad81ed5c65aada06b55c23afad5ce854" 200 218 } 201 219 }, 202 220 {
+46 -6
Package.swift
··· 15 15 name: "ATProto", 16 16 targets: ["ATProto"] 17 17 ), 18 - 18 + 19 19 .library(name: "DNSResolverClient", targets: ["DNSResolverClient"]), 20 20 .library(name: "XRPCClient", targets: ["XRPCClient"]), 21 21 .library(name: "ATProtoOAuth", targets: ["ATProtoOAuth"]), 22 + .library(name: "ATProtoServer", targets: ["ATProtoServer"]), 22 23 23 - .library(name: "TCATProtoAuthentication", targets: ["TCATProtoAuthentication"]) 24 + .library(name: "TCATProtoAuthentication", targets: ["TCATProtoAuthentication"]), 25 + .library(name: "ComposableAtmosphereAuthentication", targets: ["ComposableAtmosphereAuthentication"]), 26 + ], 27 + traits: [ 28 + .trait( 29 + name: "TCA26", 30 + description: "Enables TCA 2.6 (ComposableArchitecture2) integration" 31 + ), 32 + .trait( 33 + name: "TCA1OAuth", 34 + description: "Enables TCA 1.0 OAuth integration" 35 + ), 24 36 ], 25 37 dependencies: [ 26 38 .package(url: "https://github.com/apple/swift-async-dns-resolver", from: "0.5.0"), 39 + .package(url: "https://github.com/apple/swift-crypto", from: "3.0.0"), 27 40 28 41 .package(url: "https://github.com/pointfreeco/swift-tagged.git", .upToNextMajor(from: "0.10.0")), 29 42 .package(url: "https://github.com/pointfreeco/swift-sharing", .upToNextMajor(from: "2.0.0")), 30 43 .package(url: "https://github.com/pointfreeco/swift-dependencies", branch: "26"), 44 + 31 45 .package(url: "https://github.com/pointfreeco/TCA26", branch: "main"), 32 - // .package(path: "../3rd-party/PointFree/TCA26"), 33 46 34 47 .package(url: "https://tangled.org/woody.fm/swift-dependencies-http-client", branch: "main"), 35 48 36 49 .package(url: "https://tangled.org/woody.fm/swift-urlqueryitem-coder", branch: "main"), 37 - .package(path: "../swift-tagged-types-macro"), 50 + .package(url: "https://tangled.org/woody.fm/swift-tagged-types-macro", branch: "main"), 38 51 39 52 40 53 ], ··· 60 73 .product(name: "Dependencies", package: "swift-dependencies"), 61 74 .product(name: "DependenciesMacros", package: "swift-dependencies"), 62 75 63 - .product(name: "AsyncDNSResolver", package: "swift-async-dns-resolver"), 76 + .product( 77 + name: "AsyncDNSResolver", 78 + package: "swift-async-dns-resolver", 79 + condition: .when(platforms: [.iOS, .macOS, .tvOS, .watchOS, .visionOS]) 80 + ), 64 81 ] 65 82 ), 66 83 ··· 78 95 ), 79 96 80 97 .target( 98 + name: "ATProtoServer", 99 + dependencies: [ 100 + "ATProto", 101 + "XRPCClient", 102 + "ATProtoOAuth", 103 + ] 104 + ), 105 + 106 + .target( 81 107 name: "ATProtoOAuth", 82 108 dependencies: [ 83 109 "ATProto", 84 110 "XRPCClient", 85 111 .product(name: "HTTPClient", package: "swift-dependencies-http-client"), 86 112 .product(name: "Dependencies", package: "swift-dependencies"), 113 + .product(name: "Sharing", package: "swift-sharing"), 87 114 .product(name: "DependenciesMacros", package: "swift-dependencies"), 88 115 .product(name: "TaggedTypesMacro", package: "swift-tagged-types-macro"), 89 - 116 + .product( 117 + name: "Crypto", 118 + package: "swift-crypto", 119 + condition: .when(platforms: [.android, .linux, .windows, .wasi, .openbsd]) 120 + ), 90 121 ] 91 122 ), 92 123 .target( ··· 97 128 .product(name: "ComposableArchitecture2", package: "TCA26"), 98 129 ] 99 130 ), 131 + .target( 132 + name: "ComposableAtmosphereAuthentication", 133 + dependencies: [ 134 + "ATProtoOAuth", 135 + ] 136 + ), 137 + 100 138 .testTarget( 101 139 name: "ATProtoTests", 102 140 dependencies: [ ··· 125 163 name: "TCATProtoAuthenticationTests", 126 164 dependencies: [ 127 165 "TCATProtoAuthentication", 166 + "ATProtoOAuth", 167 + .product(name: "HTTPClient", package: "swift-dependencies-http-client"), 128 168 .product(name: "DependenciesTestSupport", package: "swift-dependencies"), 129 169 .product(name: "ComposableArchitecture2", package: "TCA26"), 130 170 ]
+17 -1
Sources/ATProto/ATProto.swift
··· 69 69 @Tagged(String.self) public enum RecordKey {} 70 70 } 71 71 72 + public enum OAuth { 73 + @TaggedTypes public enum _Tags { 74 + @Tagged(String.self) public enum Scope {} 75 + @Tagged(String.self) public enum AuthorizationCode {} 76 + @Tagged(String.self) public enum ScopeString {} 77 + @Tagged(String.self) public enum RequestURI {} 78 + @Tagged(String.self) public enum Nonce {} 79 + } 80 + } 81 + 72 82 public struct URI: Sendable, Hashable, LosslessStringCodable { 73 83 public typealias Authority = Atmosphere.Account.Identifier 74 84 ··· 140 150 extension Atmosphere.Account.Client: DependencyKey { 141 151 public static let liveValue = Atmosphere.Account.Client( 142 152 resolveHandle: { handle in 143 - @Dependency(\.dnsResolverClient) var dnsResolver 144 153 @Dependency(\.didClient) var didClient 145 154 146 155 typealias HandleResolutionError = Atmosphere.Account.HandleResolutionError ··· 148 157 // Try DNS first 149 158 var dnsError: Error? 150 159 var resolvedDID: DID? 160 + 161 + #if canImport(DNSResolverClient) && canImport(Network) 162 + @Dependency(\.dnsResolverClient) var dnsResolver 151 163 do { 152 164 if let didString = try await dnsResolver.queryTXT(name: "_atproto.\(handle.rawValue)")["did"], let did = DID(didString) { 153 165 resolvedDID = did ··· 156 168 } catch { 157 169 dnsError = error 158 170 } 171 + #else 172 + 173 + dnsError = UnimplementedFailure(description: "DNSResolver is unimplmented on this platform") 174 + #endif 159 175 160 176 // Try HTTPS fallback if DNS didn't resolve 161 177 if resolvedDID == nil {
-12
Sources/ATProtoOAuth/ATProtoOAuth.swift
··· 10 10 import Dependencies 11 11 12 12 public typealias OAuth = ATProto.OAuth 13 - extension ATProto { 14 - public enum OAuth { 15 - @TaggedTypes 16 - public enum _Tags { 17 - @Tagged(String.self) public enum AuthorizationCode {} 18 - @Tagged(String.self) public enum Scope {} 19 - @Tagged(String.self) public enum ScopeString {} 20 - @Tagged(String.self) public enum RequestURI {} 21 - @Tagged(String.self) public enum Nonce {} 22 - } 23 - } 24 - } 25 13 26 14 extension DependencyValues { 27 15 public var oauthScopes: [OAuth.Scope] {
+6 -1
Sources/ATProtoOAuth/AuthorizationServer.swift
··· 110 110 public static let code: Self = "code" 111 111 } 112 112 113 + extension OAuth.AuthorizationServer.GrantType { 114 + public static let authorizationCode: Self = "authorization_code" 115 + public static let refreshToken: Self = "refresh_token" 116 + } 117 + 113 118 enum DefaultPortKey: DependencyKey { 114 119 static let liveValue = 443 115 120 } 116 121 117 122 extension DependencyValues { 118 - var defaultPort: Int { 123 + public var defaultPort: Int { 119 124 get { self[DefaultPortKey.self] } 120 125 set { self[DefaultPortKey.self] = newValue } 121 126 }
+103 -23
Sources/ATProtoOAuth/DPoP.swift
··· 3 3 // tcatproto 4 4 // 5 5 6 + #if canImport(CryptoKit) 6 7 import CryptoKit 8 + #else 9 + @preconcurrency import Crypto 10 + #endif 7 11 import Dependencies 8 12 import Foundation 9 13 import ATProto 10 14 import HTTPClient 11 15 import HTTPTypes 12 16 import TaggedTypesMacro 17 + 18 + #if canImport(Sharing) 19 + import Sharing 20 + #endif 13 21 14 22 extension OAuth { 15 23 public typealias DPOP = DemonstratingProofOfPosession 16 24 public enum DemonstratingProofOfPosession { 17 25 @TaggedTypes public enum _Tags { 18 26 @Tagged(String.self) public enum SigningAlgorithm {} 27 + @Tagged(String.self) public enum Nonce {} 28 + 19 29 } 20 30 } 21 31 } 22 32 23 33 extension OAuth.DPOP { 24 - 25 - // MARK: - Key 26 34 27 35 public struct Key: Sendable { 28 36 private let privateKey: P256.Signing.PrivateKey ··· 47 55 method: String, 48 56 url: URL, 49 57 accessToken: OAuth.AccessToken? = nil, 50 - nonce: String? = nil 58 + nonce: Nonce? = nil 51 59 ) throws -> String { 52 60 @Dependency(\.p256Sign) var p256Sign 53 61 ··· 65 73 66 74 67 75 func withProof<T>( 68 - method: String, 69 - url: URL, 70 - accessToken: OAuth.AccessToken? = nil, 71 - nonce: String? = nil, 76 + getAccessToken: (@Sendable () -> OAuth.AccessToken?)? = nil, 77 + operation: () async throws -> T, 78 + nonce: Nonce? = nil, 79 + ) async throws -> (result: T, nonce: Nonce?) { 80 + let nonceBox = NonceBox(nonce: nonce) 81 + 82 + let result = try await withDependencies { 83 + $0.requestInterceptors.append( 84 + RequestInterceptor { request, _ in 85 + let accessToken = getAccessToken?() 86 + 87 + guard let url = request.url else { return } 88 + let proof = try await self.proof( 89 + method: request.method.rawValue, 90 + url: url, 91 + accessToken: accessToken, 92 + nonce: nonceBox.nonce 93 + ) 94 + request.headerFields[HTTPField.Name("DPoP")!] = proof 95 + if let accessToken { 96 + request.headerFields[.authorization] = "DPoP \(accessToken.rawValue)" 97 + } 98 + } 99 + ) 100 + $0.responseInterceptors.append( 101 + ResponseInterceptor { _, response, _ in 102 + if let nonce = response.headerFields[HTTPField.Name("DPoP-Nonce")!] { 103 + await nonceBox.set(.init(rawValue: nonce)) 104 + } 105 + } 106 + ) 107 + 108 + $0.expectedErrors.insert(ExpectedHTTPError(status: .unauthorized, errorCode: "use_dpop_nonce")) 109 + $0.expectedErrors.insert(ExpectedHTTPError(status: .badRequest, errorCode: "use_dpop_nonce")) 110 + 111 + $0.errorInterceptors.insert( 112 + ErrorInterceptor { request, status, headers, failureBody, retry in 113 + let accessToken = getAccessToken?() 114 + 115 + @Dependency(\.jsonDecoder) var jsonDecoder 116 + let error = try failureBody.map { try jsonDecoder.decode(ErrorBody.self, from: $0).error } 117 + 118 + guard error == "use_dpop_nonce", 119 + let nonce = headers[HTTPField.Name("DPoP-Nonce")!] 120 + else { return nil } 121 + 122 + await nonceBox.set(.init(rawValue: nonce)) 123 + var retryRequest = request 124 + retryRequest.headerFields[HTTPField.Name("DPoP")!] = try self.proof( 125 + method: request.method.rawValue, 126 + url: request.url!, 127 + accessToken: accessToken, 128 + nonce: .init(nonce) 129 + ) 130 + return try await retry(retryRequest) 131 + }, 132 + at: 0 133 + 134 + ) 135 + } operation: { 136 + try await operation() 137 + } 138 + 139 + return (result, await nonceBox.nonce) 140 + } 141 + 142 + #if canImport(Sharing) 143 + func withProof<T>( 144 + nonce: Shared<Nonce?>, 72 145 operation: () async throws -> T 73 146 ) async throws -> T { 74 - let proof = try proof(method: method, url: url, accessToken: accessToken, nonce: nonce) 75 - do { 76 - return try await withDependencies { 77 - $0.requestHeaders[HTTPField.Name("DPoP")!] = proof 78 - } operation: { 79 - try await operation() 80 - } 81 - } catch let error as HTTPError { 82 - if nonce == nil, case .httpError(_, _, let headers) = error, 83 - let serverNonce = headers[HTTPField.Name("DPoP-Nonce")!] { 84 - return try await withProof(method: method, url: url, accessToken: accessToken, nonce: serverNonce, operation: operation) 85 - } 86 - throw error 147 + let (result, newNonce) = try await withProof(operation: operation, nonce: nonce.wrappedValue) 148 + if let newNonce { 149 + nonce.withLock { $0 = newNonce } 87 150 } 151 + return result 88 152 } 153 + #endif 154 + } 155 + 156 + // MARK: - Nonce Box 157 + 158 + private actor NonceBox { 159 + init(nonce: Nonce? = nil) { 160 + self.nonce = nonce 161 + } 162 + var nonce: Nonce? 163 + func set(_ nonce: Nonce) { self.nonce = nonce } 89 164 } 90 165 91 166 // MARK: - Public Key JWK ··· 119 194 let htu: String 120 195 let iat: Int 121 196 let ath: String? 122 - let nonce: String? 197 + let nonce: Nonce? 123 198 124 - init(method: String, url: URL, accessToken: OAuth.AccessToken?, nonce: String?) { 199 + init(method: String, url: URL, accessToken: OAuth.AccessToken?, nonce: Nonce?) { 125 200 @Dependency(\.uuid) var uuid 126 201 @Dependency(\.date) var date 127 202 jti = uuid().uuidString ··· 135 210 } 136 211 } 137 212 213 + public struct ErrorBody: Error, Decodable { 214 + public let error: String 215 + public let message: String? 216 + } 138 217 } 218 + 139 219 140 220 extension OAuth.DPOP.Key: Codable { 141 221 public init(from decoder: Decoder) throws { ··· 196 276 } 197 277 198 278 extension DependencyValues { 199 - var p256Sign: @Sendable (Data, P256.Signing.PrivateKey) throws -> Data { 279 + public var p256Sign: @Sendable (Data, P256.Signing.PrivateKey) throws -> Data { 200 280 get { self[P256SignKey.self] } 201 281 set { self[P256SignKey.self] = newValue } 202 282 }
+2
Sources/ATProtoOAuth/OAuthApp.swift
··· 56 56 public let applicationType: ApplicationType 57 57 public let scope: ScopeString 58 58 public let responseTypes: [AuthorizationServer.ResponseType] 59 + public let grantTypes: [AuthorizationServer.GrantType] 59 60 public let redirectURIS: [RedirectURI] 60 61 61 62 public typealias TokenEndpoint = AuthorizationServer.TokenEndpoint ··· 72 73 case applicationType = "application_type" 73 74 case scope = "scope" 74 75 case responseTypes = "response_types" 76 + case grantTypes = "grant_types" 75 77 case redirectURIS = "redirect_uris" 76 78 case tokenEndpointAuthMethod = "token_endpoint_auth_method" 77 79 case tokenEndpointAuthSigningAlgorithm = "token_endpoint_signing_alg"
+19 -12
Sources/ATProtoOAuth/OAuthClient.swift
··· 70 70 case handleNotClaimedByDIDDocument 71 71 case didDocumentMismatch 72 72 case noPDSHost 73 - case issuerMismatch 73 + case issuerMismatch(String, String) 74 74 case stateMismatch 75 75 case noRefreshToken 76 76 case invalidCallbackURL ··· 135 135 @Dependency(\.authServerClient) var authServerClient 136 136 let metadata = try await authServerClient.fetchMetadata(pdsHost) 137 137 138 - guard metadata.issuer.description == "https://\(pdsHost.rawValue)" else { 139 - throw AuthorizationError.issuerMismatch 140 - } 141 138 142 139 let pkceChallenge = try OAuth.PKCE.Challenge() 143 140 ··· 159 156 loginHint: loginHint 160 157 ) 161 158 162 - let response: OAuth.PAR.Response = try await dpopKey.withProof(method: "POST", url: parEndpointURL.rawValue) { 159 + let response: OAuth.PAR.Response = try await dpopKey.withProof { 163 160 try await withFormEncoding(body: parRequest) { data in 164 161 try await httpClient.post(parEndpointURL.rawValue, body: data) 165 162 } 166 - } 163 + }.result 167 164 168 165 guard var components = URLComponents( 169 166 url: metadata.authorizationEndpoint.rawValue, ··· 188 185 redirectURI: redirectURI, 189 186 authServer: metadata, 190 187 accountID: account.id, 188 + pdsHost: pdsHost, 191 189 dpopKey: dpopKey 192 190 ) 193 191 return (authorizationURL: authorizationURL, session: pending) ··· 202 200 _ redirectURI: OAuth.App.RedirectURI 203 201 ) async throws -> OAuth.Session { 204 202 guard iss == pending.authServer.issuer 205 - else { throw AuthorizationError.issuerMismatch } 203 + else { throw AuthorizationError.issuerMismatch(iss.description, pending.authServer.issuer.description) } 206 204 207 205 guard state == pending.state 208 206 else { throw AuthorizationError.stateMismatch} ··· 220 218 ) 221 219 222 220 let tokenResponse: OAuth.Token.Response = try await withFormEncoding(body: tokenRequest) { data in 223 - try await pending.dpopKey.withProof(method: "POST", url: endpoint.rawValue) { 221 + try await pending.dpopKey.withProof { 224 222 try await httpClient.post(endpoint.rawValue, body: data) 225 - } 223 + }.result 226 224 } 227 225 228 226 return OAuth.Session( 229 227 accountID: pending.accountID, 228 + pdsHost: pending.pdsHost, 230 229 tokenEndpoint: pending.authServer.tokenEndpoint, 231 230 scope: tokenResponse.scope, 232 231 accessToken: tokenResponse.accessToken, ··· 245 244 246 245 247 246 @Dependency(\.httpClient) var httpClient 248 - return try await withFormEncoding(body: request) { data in 249 - try await session.dpopKey.withProof(method: "POST", url: session.tokenEndpoint.rawValue) { 250 - try await httpClient.post(session.tokenEndpoint.rawValue, body: data) 247 + return try await withDependencies { 248 + $0.errorInterceptors = [] 249 + $0.requestInterceptors = [] 250 + 251 + $0.logHeaders = true 252 + } operation: { 253 + return try await withFormEncoding(body: request) { data in 254 + try await session.dpopKey.withProof { 255 + try await httpClient.post(session.tokenEndpoint.rawValue, body: data) 256 + }.result 251 257 } 252 258 } 259 + 253 260 254 261 } 255 262 }
+4 -2
Sources/ATProtoOAuth/PAR.swift
··· 10 10 import ATProto 11 11 12 12 public extension OAuth { 13 - public typealias PAR = PushedAuthorizationRequest 14 - public enum PushedAuthorizationRequest { 13 + typealias PAR = PushedAuthorizationRequest 14 + enum PushedAuthorizationRequest { 15 15 @TaggedTypes 16 16 public enum _Tags { 17 17 @Tagged(URL.self) public enum RequestEndpoint {} ··· 26 26 public let state: OAuth.Nonce 27 27 public let scope: OAuth.ScopeString 28 28 public let responseType: OAuth.AuthorizationServer.ResponseType = .code 29 + public let grantType: String = "refresh_token" 29 30 public let loginHint: Atmosphere.Account.Identifier 30 31 31 32 public init( ··· 52 53 case state 53 54 case scope 54 55 case responseType = "response_type" 56 + case grantType = "grant_type" 55 57 case loginHint = "login_hint" 56 58 } 57 59 }
+4
Sources/ATProtoOAuth/PKCE.swift
··· 83 83 } 84 84 } 85 85 86 + #if canImport(CryptoKit) 86 87 import CryptoKit 88 + #else 89 + import Crypto 90 + #endif 87 91 enum RandomBytesKey: DependencyKey { 88 92 static let liveValue: @Sendable () -> Data = { 89 93 SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
+46 -29
Sources/ATProtoOAuth/Session.swift
··· 8 8 import Dependencies 9 9 import HTTPClient 10 10 import HTTPTypes 11 + import XRPCClient 11 12 12 13 public extension OAuth { 13 14 14 15 struct Session: Sendable, Codable, Equatable { 15 16 public let accountID: Atmosphere.Account.ID 17 + public let pdsHost: ATProto.PDSHost 16 18 public let tokenEndpoint: OAuth.AuthorizationServer.TokenEndpoint 17 19 public let scope: OAuth.Scope 18 - public let accessToken: OAuth.AccessToken 19 - public let refreshToken: OAuth.RefreshToken? 20 - public let dpopKey: OAuth.DPOP.Key 21 - 20 + public var accessToken: OAuth.AccessToken 21 + public var refreshToken: OAuth.RefreshToken? 22 + public var dpopKey: OAuth.DPOP.Key 23 + 24 + public init( 25 + accountID: Atmosphere.Account.ID, 26 + pdsHost: ATProto.PDSHost, 27 + tokenEndpoint: OAuth.AuthorizationServer.TokenEndpoint, 28 + scope: OAuth.Scope, 29 + accessToken: OAuth.AccessToken, 30 + refreshToken: OAuth.RefreshToken? = nil, 31 + dpopKey: OAuth.DPOP.Key 32 + ) { 33 + self.accountID = accountID 34 + self.pdsHost = pdsHost 35 + self.tokenEndpoint = tokenEndpoint 36 + self.scope = scope 37 + self.accessToken = accessToken 38 + self.refreshToken = refreshToken 39 + self.dpopKey = dpopKey 40 + } 22 41 public struct Pending: Sendable { 23 42 public let pkceChallenge: OAuth.PKCE.Challenge 24 43 public let state: OAuth.Nonce 25 44 public let redirectURI: OAuth.App.RedirectURI 26 45 public let authServer: OAuth.AuthorizationServer.Metadata 27 46 public let accountID: Atmosphere.Account.ID 47 + public let pdsHost: ATProto.PDSHost 28 48 public let dpopKey: OAuth.DPOP.Key 29 49 } 30 50 } ··· 34 54 public func withAuthentication<T>( 35 55 operation: () async throws -> T 36 56 ) async throws -> T { 37 - let accessToken = self.accessToken 38 - let dpopKey = self.dpopKey 39 - 57 + let pdsHost = self.pdsHost 58 + 40 59 return try await withDependencies { 41 - $0.requestInterceptors.append( 42 - RequestInterceptor { request, _ in 43 - guard let url = request.url else { return } 44 - let proof = try dpopKey.proof(method: request.method.rawValue, url: url, accessToken: accessToken) 45 - request.headerFields[HTTPField.Name("DPoP")!] = proof 46 - request.headerFields[.authorization] = "DPoP \(accessToken.rawValue)" 47 - } 48 - ) 60 + $0.pdsHost = pdsHost 49 61 } operation: { 50 - try await operation() 51 - } 62 + try await self.dpopKey.withProof(getAccessToken: { 63 + @Dependency(\.defaultSession) var defaultSession 64 + return defaultSession.accessToken 65 + }, 66 + operation: { 67 + try await operation() 68 + }) 69 + }.result 52 70 } 53 71 } 54 72 ··· 59 77 public func withDefaultSessionAuthentication<T>( 60 78 operation: () async throws -> T 61 79 ) async throws -> T { 62 - @Dependency(\.defaultSession) var session 63 - // guard let session 64 - // else { 65 - // reportIssue("No session created") 66 - // throw NoSessionError() 67 - // } 68 - return try await session.withAuthentication(operation: operation) 80 + @Dependency(\.getDefaultSession) var getDefaultSession 81 + return try await getDefaultSession().withAuthentication(operation: operation) 69 82 } 70 83 71 84 struct NoSessionError: Error {} 72 - // Inject the current authenticated session into the dependency graph so that 73 - // any feature running inside an authenticated scope can read it without having 74 - // to thread it through state manually. 85 + 75 86 extension DependencyValues { 76 87 public var defaultSession: OAuth.Session { 88 + self[DefaultSessionKey.self]() 89 + } 90 + 91 + public var getDefaultSession: @Sendable () -> OAuth.Session { 77 92 get { self[DefaultSessionKey.self] } 78 93 set { self[DefaultSessionKey.self] = newValue } 79 94 } 80 95 } 81 96 82 - public enum DefaultSessionKey: TestDependencyKey { 83 - public static let testValue: OAuth.Session = .placeholder 97 + public enum DefaultSessionKey: DependencyKey { 98 + public static let liveValue: @Sendable () -> OAuth.Session = { .placeholder } 99 + public static let testValue: @Sendable () -> OAuth.Session = { .placeholder } 84 100 } 85 101 86 102 extension OAuth.Session { 87 103 public static let placeholder = OAuth.Session( 88 104 accountID: .init(DID("did:plc:placeholder")!), 105 + pdsHost: "placeholder.example", 89 106 tokenEndpoint: .init(URL(string: "https://placeholder.example/oauth/token")!), 90 107 scope: "atproto", 91 108 accessToken: .init(rawValue: "placeholder-access-token"),
+66
Sources/ATProtoServer/Server.swift
··· 1 + // 2 + // Server.swift 3 + // tcatproto 4 + // 5 + // Created by Woodrow Melling on 4/17/26. 6 + // 7 + 8 + import ATProto 9 + import XRPCClient 10 + 11 + public extension ATProto { 12 + public enum Server { 13 + public enum Requests {} 14 + 15 + public struct Session: Decodable { 16 + public let handle: Atmosphere.Account.Handle 17 + public let did: Atmosphere.Account.ID 18 + public let didDoc: DID.Document? 19 + public let email: String? 20 + public let emailConfirmed: Bool? 21 + public let active: Bool? 22 + public let status: Status? 23 + 24 + public enum Status: String, Codable { 25 + case takendown 26 + case suspended 27 + case deactivated 28 + } 29 + } 30 + } 31 + } 32 + 33 + extension ATProto.Server.Requests { 34 + public struct GetSession: XRPC.Request { 35 + public typealias Parameters = Never 36 + public typealias Output = ATProto.Server.Session 37 + 38 + public static let nsid: ATProto.NSID = "com.atproto.server.getSession" 39 + public static let type: XRPC.RequestType = .query 40 + } 41 + 42 + } 43 + 44 + import Dependencies 45 + import DependenciesMacros 46 + 47 + public extension ATProto.Server { 48 + @DependencyClient 49 + struct Client: Sendable { 50 + public var getSession: @Sendable () async throws -> Session 51 + } 52 + } 53 + 54 + import ATProtoOAuth 55 + 56 + extension ATProto.Server.Client: DependencyKey { 57 + public static let testValue = Self() 58 + public static let liveValue = ATProto.Server.Client( 59 + getSession: { 60 + try await withDefaultSessionAuthentication { 61 + @Dependency(XRPC.Client.self) var xrpcClient 62 + return try await xrpcClient.run(ATProto.Server.Requests.GetSession()) 63 + } 64 + } 65 + ) 66 + }
+2
Sources/ComposableAtmosphereAuthentication/ComposableAtmosphereAuthentication.swift
··· 1 + // ComposableAtmosphereAuthentication 2 + // TCA 1.0 OAuth integration for ATProto
+16 -7
Sources/DNSResolverClient/DNSClient.swift
··· 7 7 8 8 import Dependencies 9 9 import DependenciesMacros 10 - import Network 11 10 import Tagged 12 11 13 - import AsyncDNSResolver 14 12 15 13 // MARK: - Tagged Types 16 14 17 15 public enum DomainNameTag {} 18 16 public typealias DNSName = Tagged<DomainNameTag, String> 19 17 20 - // MARK: - DN/ Client 18 + // MARK: - DN/ Clien 19 + #if canImport(Network) 20 + #if canImport(AsyncDNSResolver) 21 + import Network 22 + import AsyncDNSResolver 21 23 22 24 @DependencyClient 23 25 public struct DNSResolverClient: Sendable { ··· 95 97 } 96 98 97 99 100 + extension DNSResolverClient: TestDependencyKey { 101 + public static let testValue = Self() 102 + } 103 + // Android: swift-async-dns-resolver uses c-ares (C FFI) which is currently not compiling on Android by Skip. 104 + // Handle resolution falls through to the HTTPS /.well-known/atproto-did 105 + // fallback automatically when queryTXT throws. 98 106 99 - 107 + import AsyncDNSResolver 100 108 extension DNSResolverClient: DependencyKey { 101 - public static let testValue = Self() 102 109 public static let liveValue: DNSResolverClient = .init( 103 110 queryA: { name in 104 111 try await AsyncDNSResolver().queryA(name: name.rawValue) ··· 123 130 }, 124 131 queryTXT: { name in 125 132 let records = try await AsyncDNSResolver().queryTXT(name: name.rawValue) 126 - 127 133 128 134 return NWTXTRecord( 129 - Dictionary( 135 + Dictionary( 130 136 records.compactMap { 131 137 let elements = $0.txt.split(separator: "=") 132 138 ··· 154 160 set { self[DNSResolverClient.self] = newValue } 155 161 } 156 162 } 163 + 164 + #endif 165 + #endif 157 166 158 167
+71 -71
Sources/TCATProtoAuthentication/LocalCallbackServer.swift
··· 1 + //// 2 + //// LocalCallbackServer.swift 3 + //// tcatproto 4 + //// 1 5 // 2 - // LocalCallbackServer.swift 3 - // tcatproto 6 + //import Foundation 7 + //import Network 4 8 // 5 - 6 - import Foundation 7 - import Network 8 - 9 - final class LocalCallbackServer: @unchecked Sendable { 10 - 11 - /// Starts the server and returns a redirect URI with the assigned port. 12 - /// Call `waitForCallback()` after opening the browser. 13 - func start() async throws -> (redirectURI: URL, waitForCallback: () async throws -> URL) { 14 - let listener = try NWListener(using: .tcp, on: .any) 15 - 16 - // Bridge the one-shot callback into an async continuation 17 - let (callbackStream, callbackContinuation) = AsyncThrowingStream<URL, Error>.makeStream() 18 - 19 - listener.newConnectionHandler = { connection in 20 - connection.start(queue: .global()) 21 - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in 22 - if let error { 23 - callbackContinuation.finish(throwing: error) 24 - return 25 - } 26 - 27 - guard let data, 28 - let request = String(data: data, encoding: .utf8), 29 - let firstLine = request.split(separator: "\r\n", maxSplits: 1).first.map(String.init), 30 - let rawPath = firstLine.split(separator: " ").dropFirst().first.map(String.init), 31 - let port = listener.port?.rawValue, 32 - let url = URL(string: "http://127.0.0.1:\(port)\(rawPath)") 33 - else { 34 - callbackContinuation.finish(throwing: URLError(.badURL)) 35 - return 36 - } 37 - 38 - let html = "<html><body><h1>Login successful!</h1><p>You may close this window.</p></body></html>" 39 - let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: \(html.utf8.count)\r\n\r\n\(html)" 40 - connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in 41 - connection.cancel() 42 - listener.cancel() 43 - }) 44 - 45 - callbackContinuation.yield(url) 46 - callbackContinuation.finish() 47 - } 48 - } 49 - 50 - // Wait until the listener is ready and has an assigned port 51 - let port: UInt16 = try await withCheckedThrowingContinuation { continuation in 52 - listener.stateUpdateHandler = { state in 53 - switch state { 54 - case .ready: 55 - continuation.resume(returning: listener.port!.rawValue) 56 - case .failed(let error): 57 - continuation.resume(throwing: error) 58 - default: 59 - break 60 - } 61 - } 62 - listener.start(queue: .global()) 63 - } 64 - 65 - let redirectURI = URL(string: "http://127.0.0.1:\(port)/callback")! 66 - 67 - return (redirectURI, { 68 - guard let url = try await callbackStream.first(where: { _ in true }) 69 - else { throw URLError(.badServerResponse) } 70 - return url 71 - }) 72 - } 73 - } 9 + //final class LocalCallbackServer: @unchecked Sendable { 10 + // 11 + // /// Starts the server and returns a redirect URI with the assigned port. 12 + // /// Call `waitForCallback()` after opening the browser. 13 + // func start() async throws -> (redirectURI: URL, waitForCallback: () async throws -> URL) { 14 + // let listener = try NWListener(using: .tcp, on: .any) 15 + // 16 + // // Bridge the one-shot callback into an async continuation 17 + // let (callbackStream, callbackContinuation) = AsyncThrowingStream<URL, Error>.makeStream() 18 + // 19 + // listener.newConnectionHandler = { connection in 20 + // connection.start(queue: .global()) 21 + // connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in 22 + // if let error { 23 + // callbackContinuation.finish(throwing: error) 24 + // return 25 + // } 26 + // 27 + // guard let data, 28 + // let request = String(data: data, encoding: .utf8), 29 + // let firstLine = request.split(separator: "\r\n", maxSplits: 1).first.map(String.init), 30 + // let rawPath = firstLine.split(separator: " ").dropFirst().first.map(String.init), 31 + // let port = listener.port?.rawValue, 32 + // let url = URL(string: "http://127.0.0.1:\(port)\(rawPath)") 33 + // else { 34 + // callbackContinuation.finish(throwing: URLError(.badURL)) 35 + // return 36 + // } 37 + // 38 + // let html = "<html><body><h1>Login successful!</h1><p>You may close this window.</p></body></html>" 39 + // let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: \(html.utf8.count)\r\n\r\n\(html)" 40 + // connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in 41 + // connection.cancel() 42 + // listener.cancel() 43 + // }) 44 + // 45 + // callbackContinuation.yield(url) 46 + // callbackContinuation.finish() 47 + // } 48 + // } 49 + // 50 + // // Wait until the listener is ready and has an assigned port 51 + // let port: UInt16 = try await withCheckedThrowingContinuation { continuation in 52 + // listener.stateUpdateHandler = { state in 53 + // switch state { 54 + // case .ready: 55 + // continuation.resume(returning: listener.port!.rawValue) 56 + // case .failed(let error): 57 + // continuation.resume(throwing: error) 58 + // default: 59 + // break 60 + // } 61 + // } 62 + // listener.start(queue: .global()) 63 + // } 64 + // 65 + // let redirectURI = URL(string: "http://127.0.0.1:\(port)/callback")! 66 + // 67 + // return (redirectURI, { 68 + // guard let url = try await callbackStream.first(where: { _ in true }) 69 + // else { throw URLError(.badServerResponse) } 70 + // return url 71 + // }) 72 + // } 73 + //}
+2
Sources/TCATProtoAuthentication/OpenURLWhenActiveSceneModifier.swift
··· 5 5 // Created by Woodrow Meeling on 9/30/24. 6 6 // 7 7 8 + #if canImport(SwiftUI) 8 9 import SwiftUI 9 10 10 11 struct OpenURLWhenActiveSceneModifier: ViewModifier { ··· 50 51 self.modifier(OpenURLWhenActiveSceneModifier(action: action)) 51 52 } 52 53 } 54 + #endif
+60 -28
Sources/TCATProtoAuthentication/TCATProtoAuth.swift
··· 6 6 import ComposableArchitecture2 7 7 import ATProtoOAuth 8 8 import ATProto 9 + import HTTPClient 9 10 import Dependencies 11 + import Foundation 10 12 import Sharing 13 + #if canImport(SwiftUI) 11 14 import SwiftUI 15 + #endif 12 16 13 17 // MARK: - Public Event Keys 14 18 ··· 17 21 18 22 /// Post this event from anywhere in the authenticated destination to trigger logout. 19 23 public struct LogoutRequested: FeatureEventKey { 24 + public init() {} 20 25 public typealias Value = Void 21 26 } 22 27 ··· 51 56 public var loggedIn: LoggedInDestination.State? 52 57 53 58 59 + public var isLoadingAuthentication = false 54 60 var pendingSession: OAuth.Session.Pending? 55 61 var makeState: @Sendable () -> LoggedInDestination.State 56 62 57 63 @Trigger<String?> public var startLogin 58 64 59 65 public init( 60 - @FeatureBuilder<LoggedInDestination.State, LoggedInDestination.Action> makeState: @escaping @Sendable () -> LoggedInDestination.State 66 + makeState: @escaping @Sendable () -> LoggedInDestination.State 61 67 ) { 62 68 self.makeState = makeState 63 69 } ··· 87 93 let session = try await authClient.handleCallback(url: callbackURL, pending: pending) 88 94 89 95 $session.withLock { $0 = session } 96 + 97 + try store.modify { $0.isLoadingAuthentication = false } 90 98 } 99 + 91 100 case .loggedIn: 92 101 return 93 102 } 94 103 } 95 - 96 104 .onMount(id: session?.accountID) { state in 97 - // print("sessionAccountID: \(session?.accountID)") 98 105 withErrorReporting { 99 106 if let session = session, state.loggedIn == nil { 100 107 let loggedIn = state.makeState() 101 108 state.loggedIn = loggedIn 102 - try store.post(key: SessionMount.self, value: session.accountID) 109 + store.addTask { 110 + try store.post(key: SessionMount.self, value: session.accountID) 111 + } 103 112 } else { 104 - try store.modify { 105 - $0.loggedIn = nil 113 + state.loggedIn = nil 114 + store.addTask { 115 + try store.post(key: SessionDismount.self, value: nil) 106 116 } 107 - try store.post(key: SessionDismount.self, value: nil) 108 117 } 109 118 } 110 119 111 120 } 112 121 .ifLet(\.loggedIn, action: \.loggedIn) { 113 - let _ = print("making destination with sessionID: \(session?.accountID)") 114 122 makeDestinationFeature() 115 - .dependency(\.defaultSession, session ?? .placeholder) 116 - let _ = print("After making destination") 123 + .dependency(\.getDefaultSession, { [session = self.$session] in 124 + session.wrappedValue ?? .placeholder 125 + }) 126 + .transformDependency(\.errorInterceptors) { [sharedSession = self.$session] in 127 + $0.append(.tokenRefresh(session: sharedSession)) 128 + } 117 129 } 118 130 .onEvent(LogoutRequested.self) { _, _ in 119 131 $session.withLock { $0 = nil } 120 132 } 121 133 .onTrigger(store.startLogin) { loginHintString, state in 134 + state.isLoadingAuthentication = true 122 135 store.addTask { 123 136 if let loginHintString, let login = Atmosphere.Account.Identifier(loginHintString) { 124 137 @Dependency(\.oauthAppConfiguration) var oauthAppConfig ··· 136 149 case .externalBrowser: 137 150 let pending = try await authClient.beginAuthorization(loginHint: login) 138 151 try store.modify { $0.pendingSession = pending.session } 139 - @Dependency(\.openURL) var openURL 140 - await openURL(pending.authorizationURL) 141 - 142 - case .loopback: 143 - let server = LocalCallbackServer() 144 - let (redirectURI, waitForCallback) = try await server.start() 145 - let pending = try await withDependencies { 146 - $0.oauthAppConfiguration.redirectURI = .init(redirectURI) 147 - } operation: { 148 - try await authClient.beginAuthorization(loginHint: login) 149 - } 150 - try store.modify { $0.pendingSession = pending.session } 152 + #if canImport(SwiftUI) 151 153 @Dependency(\.openURL) var openURL 152 154 await openURL(pending.authorizationURL) 153 - let callbackURL = try await waitForCallback() 154 - let session = try await authClient.handleCallback(url: .init(callbackURL), pending: pending.session) 155 - $session.withLock { $0 = session } 155 + #endif 156 156 } 157 157 } 158 158 } ··· 169 169 } 170 170 171 171 // MARK: - View Modifier 172 - 172 + #if canImport(SwiftUI) 173 173 /// A view modifier that gates your app behind AT Protocol OAuth authentication. 174 174 /// 175 175 /// Shows the wrapped `content` when unauthenticated and `loggedInContent` once a session is established. ··· 210 210 modifier(WithAuthSessionViewModifier(store: store, loggedInContent: loggedInContent)) 211 211 } 212 212 } 213 + #endif 213 214 214 215 // MARK: - FeatureProtocol Extension 215 216 ··· 263 264 set { self[WebAuthenticationSession.self] = newValue } 264 265 } 265 266 } 267 + #if canImport(AuthenticationServices) 266 268 import AuthenticationServices 267 269 #if os(macOS) 268 270 import AppKit ··· 306 308 } 307 309 ) 308 310 } 311 + #else 312 + extension WebAuthenticationSession: DependencyKey { 313 + public static let liveValue = WebAuthenticationSession( 314 + open: { _, _ in fatalError("WebAuthenticationSession is not supported on this platform") } 315 + ) 316 + } 317 + #endif 309 318 310 319 // MARK: - Optional dependency modifier 311 320 ··· 339 348 public enum BrowserStrategy: Sendable, Hashable, DependencyKey, TestDependencyKey { 340 349 case webAuthenticationSession 341 350 case externalBrowser 342 - case loopback 343 351 344 - public static let liveValue = Self.loopback 352 + public static let liveValue = Self.externalBrowser 345 353 public static let testValue = Self.externalBrowser 346 354 } 347 355 } ··· 352 360 set { self[OAuth.BrowserStrategy.self] = newValue } 353 361 } 354 362 } 363 + 364 + // MARK: - ErrorInterceptor factories 365 + 366 + extension ErrorInterceptor { 367 + public static func tokenRefresh(session: Shared<OAuth.Session?>) -> ErrorInterceptor { 368 + ErrorInterceptor { failedRequest, failureCode, _, failureBody, retry in 369 + @Dependency(\.jsonDecoder) var jsonDecoder 370 + let error = try failureBody.map { try jsonDecoder.decode(OAuth.DPOP.ErrorBody.self, from: $0).error } 371 + 372 + guard failureCode == .unauthorized, 373 + error == "invalid_token" 374 + else { return nil } 375 + 376 + @Dependency(OAuth.Client.self) var client 377 + let refreshed = try await client.refreshToken(session.wrappedValue ?? .placeholder) 378 + session.withLock { 379 + $0?.accessToken = refreshed.accessToken 380 + $0?.refreshToken = refreshed.refreshToken 381 + } 382 + 383 + return try await retry(failedRequest) 384 + } 385 + } 386 + }
+11 -4
Sources/XRPCClient/XRPCClient.swift
··· 19 19 public enum XRPC { 20 20 21 21 public protocol Request { 22 - associatedtype Parameters: Encodable 23 22 associatedtype Input 23 + associatedtype Parameters 24 24 associatedtype Output: Decodable 25 - 26 25 27 26 static var nsid: ATProto.NSID { get } 28 27 static var type: RequestType { get } 28 + static var scope: ATProto.OAuth.Scope? { get } 29 29 30 30 var parameters: Parameters { get } 31 31 var input: Input { get } 32 32 33 33 var data: Data? { get } 34 34 35 - 35 + var queryItems: [URLQueryItem] { get throws } 36 36 } 37 37 38 38 ··· 57 57 58 58 59 59 60 + public extension XRPC.Request { 61 + static var scope: ATProto.OAuth.Scope? { nil } 62 + } 63 + 60 64 public extension XRPC.Request where Input == Never { 61 65 var input: Never { fatalError() } 62 66 var data: Data? { nil } ··· 64 68 65 69 public extension XRPC.Request where Parameters == Never { 66 70 var parameters: Never { fatalError() } 71 + var queryItems: [URLQueryItem] { 72 + get throws { [] } 73 + } 67 74 } 68 75 69 76 70 - public extension XRPC.Request { 77 + public extension XRPC.Request where Parameters: Encodable { 71 78 var queryItems: [URLQueryItem] { 72 79 get throws { 73 80 try URLQueryItemEncoder(arrayStrategy: .repeated).encode(self.parameters)
+287
TCATProtoDemoApp.xcodeproj/project.pbxproj
··· 1 + // !$*UTF8*$! 2 + { 3 + archiveVersion = 1; 4 + classes = { 5 + }; 6 + objectVersion = 56; 7 + objects = { 8 + 9 + /* Begin PBXBuildFile section */ 10 + 491F27822DA55B72004926EE /* TCATProtoDemoApp in Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* TCATProtoDemoApp */; }; 11 + 491F27832DA55B72004926EE /* TCATProtoDemoApp in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* TCATProtoDemoApp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 12 + 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; }; 13 + 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; }; 14 + 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; }; 15 + /* End PBXBuildFile section */ 16 + 17 + /* Begin PBXCopyFilesBuildPhase section */ 18 + 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = { 19 + isa = PBXCopyFilesBuildPhase; 20 + buildActionMask = 2147483647; 21 + dstPath = ""; 22 + dstSubfolderSpec = 10; 23 + files = ( 24 + 491F27832DA55B72004926EE /* TCATProtoDemoApp in Embed Frameworks */, 25 + ); 26 + name = "Embed Frameworks"; 27 + runOnlyForDeploymentPostprocessing = 0; 28 + }; 29 + /* End PBXCopyFilesBuildPhase section */ 30 + 31 + /* Begin PBXFileReference section */ 32 + 4900101C2BACEA710000DE33 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 33 + 493609562A6B7EAE00C401E2 /* TCATProtoDemoApp */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TCATProtoDemoApp; path = ..; sourceTree = "<group>"; }; 34 + 496BDBEB2B89A47800C09264 /* TCATProtoDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TCATProtoDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 35 + 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = ../Sources/TCATProtoDemoApp/Resources/Localizable.xcstrings; sourceTree = "<group>"; }; 36 + 496EB72F2A6AE4DE00C1253A /* Skip.env */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Skip.env; path = ../Skip.env; sourceTree = "<group>"; }; 37 + 496EB72F2A6AE4DE00C1253B /* TCATProtoDemoApp.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TCATProtoDemoApp.xcconfig; sourceTree = "<group>"; }; 38 + 496EB72F2A6AE4DE00C1253C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; }; 39 + 49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; }; 40 + 49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 41 + 49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; }; 42 + /* End PBXFileReference section */ 43 + 44 + /* Begin PBXFrameworksBuildPhase section */ 45 + 499CD43C2AC5B799001AE8D8 /* Frameworks */ = { 46 + isa = PBXFrameworksBuildPhase; 47 + buildActionMask = 2147483647; 48 + files = ( 49 + 491F27822DA55B72004926EE /* TCATProtoDemoApp in Frameworks */, 50 + ); 51 + runOnlyForDeploymentPostprocessing = 0; 52 + }; 53 + /* End PBXFrameworksBuildPhase section */ 54 + 55 + /* Begin PBXGroup section */ 56 + 496BDBEC2B89A47800C09264 /* Products */ = { 57 + isa = PBXGroup; 58 + children = ( 59 + 496BDBEB2B89A47800C09264 /* TCATProtoDemoApp.app */, 60 + ); 61 + name = Products; 62 + sourceTree = "<group>"; 63 + }; 64 + 49F90C1F2A52156200F06D93 = { 65 + isa = PBXGroup; 66 + children = ( 67 + 496EB72F2A6AE4DE00C1253C /* README.md */, 68 + 496EB72F2A6AE4DE00C1253A /* Skip.env */, 69 + 496EB72F2A6AE4DE00C1253B /* TCATProtoDemoApp.xcconfig */, 70 + 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */, 71 + 493609562A6B7EAE00C401E2 /* TCATProtoDemoApp */, 72 + 49F90C2A2A52156200F06D93 /* App */, 73 + 496BDBEC2B89A47800C09264 /* Products */, 74 + ); 75 + sourceTree = "<group>"; 76 + }; 77 + 49F90C2A2A52156200F06D93 /* App */ = { 78 + isa = PBXGroup; 79 + children = ( 80 + 49F90C2B2A52156200F06D93 /* Main.swift */, 81 + 49F90C2F2A52156300F06D93 /* Assets.xcassets */, 82 + 49F90C312A52156300F06D93 /* Entitlements.plist */, 83 + 4900101C2BACEA710000DE33 /* Info.plist */, 84 + ); 85 + name = App; 86 + sourceTree = "<group>"; 87 + }; 88 + /* End PBXGroup section */ 89 + 90 + /* Begin PBXNativeTarget section */ 91 + 499CD4382AC5B799001AE8D8 /* TCATProtoDemoApp App */ = { 92 + isa = PBXNativeTarget; 93 + buildConfigurationList = 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "TCATProtoDemoApp App" */; 94 + buildPhases = ( 95 + 499CD43A2AC5B799001AE8D8 /* Sources */, 96 + 499CD43C2AC5B799001AE8D8 /* Frameworks */, 97 + 499CD43E2AC5B799001AE8D8 /* Resources */, 98 + 499CD4452AC5B869001AE8D8 /* Run skip gradle */, 99 + 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */, 100 + ); 101 + buildRules = ( 102 + ); 103 + dependencies = ( 104 + ); 105 + name = "TCATProtoDemoApp App"; 106 + packageProductDependencies = ( 107 + 491F27812DA55B72004926EE /* TCATProtoDemoApp */, 108 + ); 109 + productName = App; 110 + productReference = 496BDBEB2B89A47800C09264 /* TCATProtoDemoApp.app */; 111 + productType = "com.apple.product-type.application"; 112 + }; 113 + /* End PBXNativeTarget section */ 114 + 115 + /* Begin PBXProject section */ 116 + 49F90C202A52156200F06D93 /* Project object */ = { 117 + isa = PBXProject; 118 + attributes = { 119 + BuildIndependentTargetsInParallel = 1; 120 + LastSwiftUpdateCheck = 1430; 121 + LastUpgradeCheck = 1630; 122 + }; 123 + buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "TCATProtoDemoApp" */; 124 + compatibilityVersion = "Xcode 14.0"; 125 + developmentRegion = en; 126 + hasScannedForEncodings = 0; 127 + knownRegions = ( 128 + en, 129 + Base, 130 + es, 131 + ja, 132 + "zh-Hans", 133 + ); 134 + mainGroup = 49F90C1F2A52156200F06D93; 135 + packageReferences = ( 136 + ); 137 + productRefGroup = 496BDBEC2B89A47800C09264 /* Products */; 138 + projectDirPath = ""; 139 + projectRoot = ""; 140 + targets = ( 141 + 499CD4382AC5B799001AE8D8 /* TCATProtoDemoApp App */, 142 + ); 143 + }; 144 + /* End PBXProject section */ 145 + 146 + /* Begin PBXResourcesBuildPhase section */ 147 + 499CD43E2AC5B799001AE8D8 /* Resources */ = { 148 + isa = PBXResourcesBuildPhase; 149 + buildActionMask = 2147483647; 150 + files = ( 151 + 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */, 152 + 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */, 153 + ); 154 + runOnlyForDeploymentPostprocessing = 0; 155 + }; 156 + /* End PBXResourcesBuildPhase section */ 157 + 158 + /* Begin PBXShellScriptBuildPhase section */ 159 + 499CD4452AC5B869001AE8D8 /* Run skip gradle */ = { 160 + isa = PBXShellScriptBuildPhase; 161 + alwaysOutOfDate = 1; 162 + buildActionMask = 2147483647; 163 + files = ( 164 + ); 165 + inputFileListPaths = ( 166 + ); 167 + inputPaths = ( 168 + ); 169 + name = "Run skip gradle"; 170 + outputFileListPaths = ( 171 + ); 172 + outputPaths = ( 173 + ); 174 + runOnlyForDeploymentPostprocessing = 0; 175 + shellPath = "/bin/sh -e"; 176 + shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n echo \"note: skipping skip due to SKIP_ZERO\"\n exit 0\nelif [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then\n echo \"note: skipping skip due to ENABLE_PREVIEWS\"\n exit 0\nelif [ \"${ACTION}\" = \"install\" ]; then\n echo \"note: skipping skip due to archive install\"\n exit 0\nelif [ \"${SKIP_ACTION}\" = \"none\" ]; then\n echo \"note: skipping skip due to SKIP_ACTION none\"\n exit 0\nelse\n SKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Release:${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n"; 177 + }; 178 + /* End PBXShellScriptBuildPhase section */ 179 + 180 + /* Begin PBXSourcesBuildPhase section */ 181 + 499CD43A2AC5B799001AE8D8 /* Sources */ = { 182 + isa = PBXSourcesBuildPhase; 183 + buildActionMask = 2147483647; 184 + files = ( 185 + 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */, 186 + ); 187 + runOnlyForDeploymentPostprocessing = 0; 188 + }; 189 + /* End PBXSourcesBuildPhase section */ 190 + 191 + /* Begin XCBuildConfiguration section */ 192 + 499CD4422AC5B799001AE8D8 /* Debug */ = { 193 + isa = XCBuildConfiguration; 194 + baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* TCATProtoDemoApp.xcconfig */; 195 + buildSettings = { 196 + DEVELOPMENT_TEAM = FYZE6539TN; 197 + ENABLE_PREVIEWS = YES; 198 + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; 199 + INFOPLIST_KEY_LSApplicationCategoryType = ""; 200 + IPHONEOS_DEPLOYMENT_TARGET = 18.6; 201 + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 202 + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 203 + }; 204 + name = Debug; 205 + }; 206 + 499CD4432AC5B799001AE8D8 /* Release */ = { 207 + isa = XCBuildConfiguration; 208 + baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* TCATProtoDemoApp.xcconfig */; 209 + buildSettings = { 210 + DEVELOPMENT_TEAM = FYZE6539TN; 211 + ENABLE_PREVIEWS = YES; 212 + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; 213 + INFOPLIST_KEY_LSApplicationCategoryType = ""; 214 + IPHONEOS_DEPLOYMENT_TARGET = 18.6; 215 + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 216 + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 217 + }; 218 + name = Release; 219 + }; 220 + 49F90C4B2A52156300F06D93 /* Debug */ = { 221 + isa = XCBuildConfiguration; 222 + buildSettings = { 223 + ALWAYS_SEARCH_USER_PATHS = NO; 224 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 225 + COPY_PHASE_STRIP = NO; 226 + DEBUG_INFORMATION_FORMAT = dwarf; 227 + ENABLE_STRICT_OBJC_MSGSEND = YES; 228 + ENABLE_TESTABILITY = YES; 229 + ENABLE_USER_SCRIPT_SANDBOXING = NO; 230 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 231 + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 232 + MTL_FAST_MATH = YES; 233 + ONLY_ACTIVE_ARCH = YES; 234 + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 235 + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 236 + }; 237 + name = Debug; 238 + }; 239 + 49F90C4C2A52156300F06D93 /* Release */ = { 240 + isa = XCBuildConfiguration; 241 + buildSettings = { 242 + ALWAYS_SEARCH_USER_PATHS = NO; 243 + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 244 + COPY_PHASE_STRIP = NO; 245 + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 246 + ENABLE_NS_ASSERTIONS = NO; 247 + ENABLE_STRICT_OBJC_MSGSEND = YES; 248 + ENABLE_USER_SCRIPT_SANDBOXING = NO; 249 + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 250 + MTL_ENABLE_DEBUG_INFO = NO; 251 + MTL_FAST_MATH = YES; 252 + SWIFT_COMPILATION_MODE = wholemodule; 253 + }; 254 + name = Release; 255 + }; 256 + /* End XCBuildConfiguration section */ 257 + 258 + /* Begin XCConfigurationList section */ 259 + 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "TCATProtoDemoApp App" */ = { 260 + isa = XCConfigurationList; 261 + buildConfigurations = ( 262 + 499CD4422AC5B799001AE8D8 /* Debug */, 263 + 499CD4432AC5B799001AE8D8 /* Release */, 264 + ); 265 + defaultConfigurationIsVisible = 0; 266 + defaultConfigurationName = Release; 267 + }; 268 + 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "TCATProtoDemoApp" */ = { 269 + isa = XCConfigurationList; 270 + buildConfigurations = ( 271 + 49F90C4B2A52156300F06D93 /* Debug */, 272 + 49F90C4C2A52156300F06D93 /* Release */, 273 + ); 274 + defaultConfigurationIsVisible = 0; 275 + defaultConfigurationName = Release; 276 + }; 277 + /* End XCConfigurationList section */ 278 + 279 + /* Begin XCSwiftPackageProductDependency section */ 280 + 491F27812DA55B72004926EE /* TCATProtoDemoApp */ = { 281 + isa = XCSwiftPackageProductDependency; 282 + productName = TCATProtoDemoApp; 283 + }; 284 + /* End XCSwiftPackageProductDependency section */ 285 + }; 286 + rootObject = 49F90C202A52156200F06D93 /* Project object */; 287 + }
+83
TCATProtoDemoApp.xcodeproj/xcshareddata/xcschemes/TCATProtoDemoApp App.xcscheme
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <Scheme 3 + LastUpgradeVersion = "1630" 4 + version = "1.7"> 5 + <BuildAction 6 + parallelizeBuildables = "YES" 7 + buildImplicitDependencies = "YES" 8 + buildArchitectures = "Automatic"> 9 + <BuildActionEntries> 10 + <BuildActionEntry 11 + buildForTesting = "YES" 12 + buildForRunning = "YES" 13 + buildForProfiling = "YES" 14 + buildForArchiving = "YES" 15 + buildForAnalyzing = "YES"> 16 + <BuildableReference 17 + BuildableIdentifier = "primary" 18 + BlueprintIdentifier = "499CD4382AC5B799001AE8D8" 19 + BuildableName = ".app" 20 + BlueprintName = "TCATProtoDemoApp App" 21 + ReferencedContainer = "container:TCATProtoDemoApp.xcodeproj"> 22 + </BuildableReference> 23 + </BuildActionEntry> 24 + </BuildActionEntries> 25 + </BuildAction> 26 + <TestAction 27 + buildConfiguration = "Debug" 28 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 29 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 30 + shouldUseLaunchSchemeArgsEnv = "YES"> 31 + <TestPlans> 32 + <TestPlanReference 33 + reference = "container:../../../TCA26Tests.xctestplan" 34 + default = "YES"> 35 + </TestPlanReference> 36 + </TestPlans> 37 + </TestAction> 38 + <LaunchAction 39 + buildConfiguration = "Debug" 40 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 41 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 42 + launchStyle = "0" 43 + useCustomWorkingDirectory = "NO" 44 + ignoresPersistentStateOnLaunch = "NO" 45 + debugDocumentVersioning = "YES" 46 + debugServiceExtension = "internal" 47 + allowLocationSimulation = "YES"> 48 + <BuildableProductRunnable 49 + runnableDebuggingMode = "0"> 50 + <BuildableReference 51 + BuildableIdentifier = "primary" 52 + BlueprintIdentifier = "499CD4382AC5B799001AE8D8" 53 + BuildableName = ".app" 54 + BlueprintName = "TCATProtoDemoApp App" 55 + ReferencedContainer = "container:TCATProtoDemoApp.xcodeproj"> 56 + </BuildableReference> 57 + </BuildableProductRunnable> 58 + </LaunchAction> 59 + <ProfileAction 60 + buildConfiguration = "Release" 61 + shouldUseLaunchSchemeArgsEnv = "YES" 62 + savedToolIdentifier = "" 63 + useCustomWorkingDirectory = "NO" 64 + debugDocumentVersioning = "YES"> 65 + <BuildableProductRunnable 66 + runnableDebuggingMode = "0"> 67 + <BuildableReference 68 + BuildableIdentifier = "primary" 69 + BlueprintIdentifier = "499CD4382AC5B799001AE8D8" 70 + BuildableName = ".app" 71 + BlueprintName = "TCATProtoDemoApp App" 72 + ReferencedContainer = "container:TCATProtoDemoApp.xcodeproj"> 73 + </BuildableReference> 74 + </BuildableProductRunnable> 75 + </ProfileAction> 76 + <AnalyzeAction 77 + buildConfiguration = "Debug"> 78 + </AnalyzeAction> 79 + <ArchiveAction 80 + buildConfiguration = "Release" 81 + revealArchiveInOrganizer = "YES"> 82 + </ArchiveAction> 83 + </Scheme>
+226 -3
Tests/ATProtoOAuthTests/AuthenticationTests.swift
··· 42 42 """.utf8) 43 43 } 44 44 45 - private func authTestSession() throws -> OAuth.Session { 45 + private func authTestSession( 46 + accessToken: OAuth.AccessToken = authTestAccessToken, 47 + refreshToken: OAuth.RefreshToken = authTestRefreshToken 48 + ) throws -> OAuth.Session { 46 49 let metadata = try JSONDecoder().decode( 47 50 OAuth.AuthorizationServer.Metadata.self, 48 51 from: authTestASMetadata() 49 52 ) 50 53 return OAuth.Session( 51 54 accountID: .init(DID("did:plc:alice123")!), 55 + pdsHost: "pds.example.com", 52 56 tokenEndpoint: metadata.tokenEndpoint, 53 57 scope: "atproto", 54 - accessToken: authTestAccessToken, 55 - refreshToken: authTestRefreshToken, 58 + accessToken: accessToken, 59 + refreshToken: refreshToken, 56 60 dpopKey: OAuth.DPOP.Key() 57 61 ) 58 62 } ··· 87 91 88 92 try await withDependencies { 89 93 $0.httpClient = .liveValue 94 + $0.getDefaultSession = { session } 90 95 $0.dataForURL = { request, _ in 91 96 await capture.record(request) 92 97 return (Data(), HTTPResponse(status: .ok)) ··· 110 115 111 116 let result = try await withDependencies { 112 117 $0.httpClient = .liveValue 118 + $0.getDefaultSession = { session } 113 119 $0.dataForURL = { _, _ in (Data(), HTTPResponse(status: .ok)) } 114 120 } operation: { 115 121 try await session.withAuthentication { ··· 120 126 #expect(result == 42) 121 127 } 122 128 } 129 + 130 + // MARK: - DPoP Nonce Retry 131 + 132 + @Suite( 133 + "DPoP nonce retry", 134 + .dependencies { 135 + $0.defaultPort = 443 136 + $0.uuid = .constant(UUID(0)) 137 + $0.date = .constant(authFixedDate) 138 + $0.p256Sign = { try $1.signature(for: $0).rawRepresentation } 139 + } 140 + ) 141 + struct DPoPNonceRetryTests { 142 + 143 + @Test("Retries with nonce after use_dpop_nonce challenge") 144 + func retriesWithNonce() async throws { 145 + let session = try authTestSession() 146 + 147 + actor RequestLog { 148 + var requests: [HTTPRequest] = [] 149 + func record(_ r: HTTPRequest) { requests.append(r) } 150 + } 151 + let log = RequestLog() 152 + 153 + try await withDependencies { 154 + $0.httpClient = .liveValue 155 + $0.getDefaultSession = { session } 156 + $0.dataForURL = { request, _ in 157 + await log.record(request) 158 + let count = await log.requests.count 159 + if count == 1 { 160 + var response = HTTPResponse(status: .unauthorized) 161 + response.headerFields[HTTPField.Name("DPoP-Nonce")!] = "server-nonce-xyz" 162 + let body = #"{"error":"use_dpop_nonce","message":"nonce required"}"#.data(using: .utf8)! 163 + return (body, response) 164 + } 165 + return (Data(), HTTPResponse(status: .ok)) 166 + } 167 + } operation: { 168 + try await session.withAuthentication { 169 + @Dependency(\.httpClient) var httpClient 170 + try await httpClient.delete(URL(string: "https://pds.example.com/xrpc/test")!) 171 + } 172 + } 173 + 174 + let requests = await log.requests 175 + #expect(requests.count == 2) 176 + 177 + let retryNonce = try decodeProofPayload(from: requests[1]).nonce 178 + #expect(retryNonce == "server-nonce-xyz") 179 + } 180 + 181 + @Test("Non-nonce 401 is not retried") 182 + func nonNonce401IsNotRetried() async throws { 183 + let session = try authTestSession() 184 + 185 + actor RequestLog { 186 + var count = 0 187 + func increment() { count += 1 } 188 + } 189 + let log = RequestLog() 190 + 191 + do { 192 + try await withDependencies { 193 + $0.httpClient = .liveValue 194 + $0.getDefaultSession = { session } 195 + $0.dataForURL = { _, _ in 196 + await log.increment() 197 + let body = #"{"error":"InvalidToken","message":"bad token"}"#.data(using: .utf8)! 198 + return (body, HTTPResponse(status: .unauthorized)) 199 + } 200 + } operation: { 201 + try await session.withAuthentication { 202 + @Dependency(\.httpClient) var httpClient 203 + try await httpClient.delete(URL(string: "https://pds.example.com/xrpc/test")!) 204 + } 205 + } 206 + } catch {} 207 + 208 + #expect(await log.count == 1) 209 + } 210 + 211 + @Test("Proactively includes nonce from successful response on next request") 212 + func proactiveNonceReuse() async throws { 213 + let session = try authTestSession() 214 + 215 + actor RequestLog { 216 + var requests: [HTTPRequest] = [] 217 + func record(_ r: HTTPRequest) { requests.append(r) } 218 + } 219 + let log = RequestLog() 220 + 221 + try await withDependencies { 222 + $0.httpClient = .liveValue 223 + $0.getDefaultSession = { session } 224 + $0.dataForURL = { request, _ in 225 + await log.record(request) 226 + var response = HTTPResponse(status: .ok) 227 + response.headerFields[HTTPField.Name("DPoP-Nonce")!] = "proactive-nonce" 228 + return (Data(), response) 229 + } 230 + } operation: { 231 + try await session.withAuthentication { 232 + @Dependency(\.httpClient) var httpClient 233 + let url = URL(string: "https://pds.example.com/xrpc/test")! 234 + try await httpClient.delete(url) 235 + try await httpClient.delete(url) 236 + } 237 + } 238 + 239 + let requests = await log.requests 240 + #expect(requests.count == 2) 241 + let secondNonce = try decodeProofPayload(from: requests[1]).nonce 242 + #expect(secondNonce == "proactive-nonce") 243 + } 244 + } 245 + 246 + // MARK: - Token Refresh Retry 247 + 248 + @Suite( 249 + "Token refresh retry", 250 + .dependencies { 251 + $0.defaultPort = 443 252 + $0.uuid = .constant(UUID(0)) 253 + $0.date = .constant(authFixedDate) 254 + $0.p256Sign = { try $1.signature(for: $0).rawRepresentation } 255 + } 256 + ) 257 + struct TokenRefreshRetryTests { 258 + 259 + @Test("Uses fresh access token on retry after refresh") 260 + func usesFreshTokenOnRetry() async throws { 261 + let originalToken = OAuth.AccessToken(rawValue: "original-token") 262 + let refreshedToken = OAuth.AccessToken(rawValue: "refreshed-token") 263 + let session = try authTestSession(accessToken: originalToken) 264 + 265 + actor RequestLog { 266 + var authHeaders: [String] = [] 267 + func record(_ r: HTTPRequest) { 268 + if let auth = r.headerFields[.authorization] { 269 + authHeaders.append(auth) 270 + } 271 + } 272 + } 273 + let log = RequestLog() 274 + 275 + try await withDependencies { 276 + $0.httpClient = .liveValue 277 + $0.getDefaultSession = { 278 + // After refresh, return session with new token 279 + let headers = log 280 + return session 281 + } 282 + $0.errorInterceptors = [ 283 + ErrorInterceptor { request, status, _, body, retry in 284 + guard status == .unauthorized else { return nil } 285 + // Simulate token refresh by updating getDefaultSession 286 + return try await withDependencies { 287 + $0.getDefaultSession = { 288 + try! authTestSession(accessToken: refreshedToken) 289 + } 290 + } operation: { 291 + try await retry(request) 292 + } 293 + } 294 + ] 295 + $0.dataForURL = { request, _ in 296 + await log.record(request) 297 + let headers = await log.authHeaders 298 + if headers.count == 1 { 299 + let body = #"{"error":"invalid_token","message":"exp check failed"}"#.data(using: .utf8)! 300 + return (body, HTTPResponse(status: .unauthorized)) 301 + } 302 + return (Data(), HTTPResponse(status: .ok)) 303 + } 304 + } operation: { 305 + try await session.withAuthentication { 306 + @Dependency(\.httpClient) var httpClient 307 + try await httpClient.delete(URL(string: "https://pds.example.com/xrpc/test")!) 308 + } 309 + } 310 + 311 + let headers = await log.authHeaders 312 + #expect(headers.count == 2) 313 + #expect(headers[0] == "DPoP \(originalToken.rawValue)") 314 + #expect(headers[1] == "DPoP \(refreshedToken.rawValue)") 315 + } 316 + } 317 + 318 + // MARK: - Helpers 319 + 320 + private struct ProofPayloadSpy: Decodable { 321 + let jti: String 322 + let htm: String 323 + let htu: String 324 + let iat: Int 325 + let ath: String? 326 + let nonce: String? 327 + } 328 + 329 + private func decodeProofPayload(from request: HTTPRequest) throws -> ProofPayloadSpy { 330 + let proof = try #require(request.headerFields[HTTPField.Name("DPoP")!]) 331 + let parts = proof.split(separator: ".", omittingEmptySubsequences: false).map(String.init) 332 + let data = try #require(Data(base64URLEncoded: parts[1])) 333 + return try JSONDecoder().decode(ProofPayloadSpy.self, from: data) 334 + } 335 + 336 + private extension Data { 337 + init?(base64URLEncoded string: String) { 338 + var base64 = string 339 + .replacingOccurrences(of: "-", with: "+") 340 + .replacingOccurrences(of: "_", with: "/") 341 + let remainder = base64.count % 4 342 + if remainder != 0 { base64 += String(repeating: "=", count: 4 - remainder) } 343 + self.init(base64Encoded: base64) 344 + } 345 + }
+2
Tests/ATProtoOAuthTests/AuthorizationServerTests.swift
··· 37 37 #expect(metadata.codeChallengeMethodsSupported.contains(.s256)) 38 38 } 39 39 40 + #if canImport(Network) 40 41 @Test func fetchesLiveMetadata() async throws { 41 42 let url = URL(string: "https://bsky.social/.well-known/oauth-authorization-server")! 42 43 let (data, _) = try await URLSession.shared.data(from: url) ··· 46 47 #expect(metadata.requirePushedAuthorizationRequests == true) 47 48 #expect(metadata.supportedDPOPSigningAlgValues.contains(.es256)) 48 49 } 50 + #endif 49 51 } 50 52 51 53 }
-16
Tests/ATProtoOAuthTests/BeginAuthorizationTests.swift
··· 203 203 } 204 204 } 205 205 206 - @Test("Throws when AS issuer does not match PDS host") 207 - func throwsWhenIssuerMismatch() async throws { 208 - try await withDependencies { 209 - $0[OAuth.AuthorizationServer.Client.self].fetchMetadata = { _ in 210 - try JSONDecoder().decode( 211 - OAuth.AuthorizationServer.Metadata.self, 212 - from: testASMetadata(issuer: "https://malicious.example.com") 213 - ) 214 - } 215 - $0.httpClient = .testClient { _, _, _ in "" } 216 - } operation: { 217 - await #expect(throws: OAuth.Client.AuthorizationError.issuerMismatch) { 218 - _ = try await client.beginAuthorization(.handle(testHandle)) 219 - } 220 - } 221 - } 222 206 223 207 @Test("Throws when DID document ID does not match DID login hint") 224 208 func throwsWhenDIDDocumentMismatch() async throws {
+2
Tests/ATProtoOAuthTests/CallbackTests.swift
··· 41 41 redirectURI: OAuth.App.RedirectURI(URL(string: "https://myapp.example.com/callback")!), 42 42 authServer: metadata, 43 43 accountID: .init(DID("did:plc:alice123")!), 44 + pdsHost: .init("host"), 44 45 dpopKey: OAuth.DPOP.Key() 45 46 ) 46 47 } ··· 48 49 private func callbackTestSession(pending: OAuth.Session.Pending) -> OAuth.Session { 49 50 OAuth.Session( 50 51 accountID: pending.accountID, 52 + pdsHost: pending.pdsHost, 51 53 tokenEndpoint: pending.authServer.tokenEndpoint, 52 54 scope: "atproto", 53 55 accessToken: callbackAccessToken,
+4
Tests/ATProtoOAuthTests/DPoPTests.swift
··· 3 3 // tcatproto 4 4 // 5 5 6 + #if canImport(CryptoKit) 6 7 import CryptoKit 8 + #else 9 + import Crypto 10 + #endif 7 11 import Dependencies 8 12 import DependenciesTestSupport 9 13 import Foundation
+3 -1
Tests/ATProtoOAuthTests/ExchangeCodeTests.swift
··· 75 75 from: testASMetadata() 76 76 ), 77 77 accountID: testAccount.id, 78 + pdsHost: .init("testpdshost"), 78 79 dpopKey: OAuth.DPOP.Key() 79 80 ) 80 81 } ··· 105 106 try await withDependencies { 106 107 $0.httpClient = .testClient { _, _, _ in "" } 107 108 } operation: { 108 - await #expect(throws: OAuth.Client.AuthorizationError.issuerMismatch) { 109 + await #expect(throws: OAuth.Client.AuthorizationError.issuerMismatch(wrongIss.description, pending.authServer.issuer.description)) { 109 110 _ = try await client.exchangeCode(pending, testCode, testState, wrongIss) 110 111 } 111 112 } ··· 174 175 let pending = try testPending() 175 176 return OAuth.Session( 176 177 accountID: pending.accountID, 178 + pdsHost: pending.pdsHost, 177 179 tokenEndpoint: pending.authServer.tokenEndpoint, 178 180 scope: "atproto", 179 181 accessToken: testAccessToken,
+14 -8
Tests/ATProtoTests/HandleResolutionTests.swift
··· 1 1 import Testing 2 2 import Dependencies 3 3 @testable import ATProto 4 + #if canImport(Network) 4 5 import Network 6 + import DNSResolverClient 7 + #endif 5 8 import HTTPClient 6 9 import Foundation 7 - import DNSResolverClient 8 10 11 + #if canImport(Network) 9 12 /// Tests for AT Protocol handle resolution based on examples from https://overreacted.io/where-its-at/ 10 13 extension ATProtoTests { 11 14 @Suite( ··· 33 36 typealias Handle = Atmosphere.Account.Handle 34 37 35 38 // MARK: - DNS-based Handle Resolution 36 - @Test("Resolve handle via DNS TXT record - example.com") 39 + @Test("Resolve handle via DNS TXT record - example.com", .tags(.dnsResolution)) 37 40 func resolveDNSHandle() async throws { 38 41 let handle = Atmosphere.Account.Handle("example.com") 39 42 let expectedDID = DID("did:plc:abc123")! ··· 50 53 } 51 54 } 52 55 53 - @Test("Resolve handle via DNS TXT record - alice.example.com") 56 + @Test("Resolve handle via DNS TXT record - alice.example.com", .tags(.dnsResolution)) 54 57 func resolveDNSHandleSubdomain() async throws { 55 58 let handle = Atmosphere.Account.Handle("alice.example.com") 56 59 let expectedDID = DID("did:plc:alice123")! ··· 112 115 113 116 // MARK: - Different DID Methods 114 117 115 - @Test("Resolve handle with did:plc method") 118 + @Test("Resolve handle with did:plc method", .tags(.dnsResolution)) 116 119 func resolvePLCMethod() async throws { 117 120 let handle = Atmosphere.Account.Handle("user.bsky.social") 118 121 let expectedDID = DID("did:plc:z72i7hdynmk6r22z27h6tvur")! ··· 128 131 } 129 132 } 130 133 131 - @Test("Resolve handle with did:web method") 134 + @Test("Resolve handle with did:web method", .tags(.dnsResolution)) 132 135 func resolveWebMethod() async throws { 133 136 let handle = Atmosphere.Account.Handle("dan.example.com") 134 137 let expectedDID = DID("did:web:dan.example.com")! ··· 146 149 147 150 // MARK: - Edge Cases 148 151 149 - @Test("Multiple TXT records, only 'did' key matters") 152 + @Test("Multiple TXT records, only 'did' key matters", .tags(.dnsResolution)) 150 153 func multipleTXTRecords() async throws { 151 154 let handle = Atmosphere.Account.Handle("eve.example.com") 152 155 let expectedDID = DID("did:plc:eve789")! ··· 165 168 } 166 169 } 167 170 168 - @Test("Complex subdomain handle") 171 + @Test("Complex subdomain handle", .tags(.dnsResolution)) 169 172 func complexSubdomainHandle() async throws { 170 173 let handle = Atmosphere.Account.Handle("user.team.company.example.com") 171 174 let expectedDID = DID("did:plc:complex123")! ··· 390 393 } 391 394 } 392 395 } 393 - 396 + #endif 394 397 395 398 extension Tag { 396 399 @Tag static var liveNetworkTests: Self 400 + /// Tests that exercise the DNS-first code path. On Android, the live DNS client 401 + /// is unavailable and handle resolution goes straight to the HTTPS fallback. 402 + @Tag static var dnsResolution: Self 397 403 } 398 404 // MARK: - DID Parsing Tests 399 405
+247
Tests/TCATProtoAuthenticationTests/TokenRefreshInterceptorTests.swift
··· 1 + // 2 + // TokenRefreshInterceptorTests.swift 3 + // tcatproto 4 + // 5 + 6 + import Testing 7 + import Foundation 8 + import Dependencies 9 + import DependenciesTestSupport 10 + import HTTPClient 11 + import HTTPTypes 12 + import ATProto 13 + import ATProtoOAuth 14 + import Sharing 15 + @testable import TCATProtoAuthentication 16 + 17 + // MARK: - Fixtures 18 + 19 + private let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) 20 + 21 + private func testASMetadata() -> Data { 22 + Data(""" 23 + { 24 + "issuer": "https://bsky.social", 25 + "authorization_endpoint": "https://bsky.social/oauth/authorize", 26 + "token_endpoint": "https://bsky.social/oauth/token", 27 + "response_types_supported": ["code"], 28 + "grant_types_supported": ["authorization_code", "refresh_token"], 29 + "code_challenge_methods_supported": ["S256"], 30 + "token_endpoint_auth_methods_supported": ["none"], 31 + "token_endpoint_auth_signing_alg_values_supported": ["ES256"], 32 + "scopes_supported": ["atproto"], 33 + "authorization_response_iss_parameter_supported": true, 34 + "require_pushed_authorization_requests": true, 35 + "pushed_authorization_request_endpoint": "https://bsky.social/oauth/par", 36 + "dpop_signing_alg_values_supported": ["ES256"], 37 + "require_request_uri_registration": true, 38 + "client_id_metadata_document_supported": true 39 + } 40 + """.utf8) 41 + } 42 + 43 + private func tokenRefreshResponseData( 44 + accessToken: String = "refreshed-token", 45 + refreshToken: String = "new-refresh-token" 46 + ) -> Data { 47 + Data(""" 48 + { 49 + "access_token": "\(accessToken)", 50 + "refresh_token": "\(refreshToken)", 51 + "token_type": "DPoP", 52 + "expires_in": 3600, 53 + "scope": "atproto" 54 + } 55 + """.utf8) 56 + } 57 + 58 + private func testSession( 59 + accessToken: OAuth.AccessToken = .init(rawValue: "original-token"), 60 + refreshToken: OAuth.RefreshToken = .init(rawValue: "refresh-token") 61 + ) throws -> OAuth.Session { 62 + let metadata = try JSONDecoder().decode(OAuth.AuthorizationServer.Metadata.self, from: testASMetadata()) 63 + return OAuth.Session( 64 + accountID: .init(DID("did:plc:alice123")!), 65 + pdsHost: "pds.example.com", 66 + tokenEndpoint: metadata.tokenEndpoint, 67 + scope: "atproto", 68 + accessToken: accessToken, 69 + refreshToken: refreshToken, 70 + dpopKey: OAuth.DPOP.Key() 71 + ) 72 + } 73 + 74 + private let testURL = URL(string: "https://pds.example.com/xrpc/com.atproto.server.getSession")! 75 + 76 + // MARK: - Suite 77 + 78 + @Suite( 79 + "ErrorInterceptor.tokenRefresh", 80 + .dependencies { 81 + $0.defaultPort = 443 82 + $0.uuid = .constant(UUID(0)) 83 + $0.date = .constant(fixedDate) 84 + $0.p256Sign = { try $1.signature(for: $0).rawRepresentation } 85 + $0.jsonDecoder = JSONDecoder() 86 + } 87 + ) 88 + struct TokenRefreshInterceptorTests { 89 + 90 + @Test("Retries with refreshed access token on 401 invalid_token") 91 + func retriesWithRefreshedToken() async throws { 92 + let originalToken = OAuth.AccessToken(rawValue: "original-token") 93 + let refreshedToken = OAuth.AccessToken(rawValue: "refreshed-token") 94 + let session = try testSession(accessToken: originalToken) 95 + 96 + @Shared(.inMemory("sesssion")) var sharedSession: OAuth.Session? = session 97 + 98 + actor RequestLog { 99 + var authHeaders: [String] = [] 100 + func record(_ r: HTTPRequest) { 101 + if let auth = r.headerFields[.authorization] { authHeaders.append(auth) } 102 + } 103 + } 104 + let log = RequestLog() 105 + 106 + try await withDependencies { 107 + $0.httpClient = .liveValue 108 + $0.getDefaultSession = { [$sharedSession] in 109 + $sharedSession.wrappedValue ?? .placeholder } 110 + $0[OAuth.Client.self].refreshToken = { _ in 111 + try JSONDecoder().decode( 112 + OAuth.Token.Response.self, 113 + from: tokenRefreshResponseData(accessToken: refreshedToken.rawValue) 114 + ) 115 + } 116 + 117 + $0.errorInterceptors = [.tokenRefresh(session: $sharedSession)] 118 + $0.dataForURL = { request, _ in 119 + await log.record(request) 120 + if await log.authHeaders.count == 1 { 121 + let body = #"{"error":"invalid_token","message":"\"exp\" claim timestamp check failed"}"#.data(using: .utf8)! 122 + return (body, HTTPResponse(status: .unauthorized)) 123 + } 124 + return (Data("{}".utf8), HTTPResponse(status: .ok)) 125 + } 126 + } operation: { 127 + try await withDefaultSessionAuthentication { 128 + @Dependency(\.httpClient) var httpClient 129 + let response: CodableVoid = try await httpClient.get(testURL) 130 + } 131 + } 132 + 133 + let headers = await log.authHeaders 134 + try #require(headers.count == 2) 135 + #expect(headers[0] == "DPoP \(originalToken.rawValue)") 136 + #expect(headers[1] == "DPoP \(refreshedToken.rawValue)") 137 + } 138 + 139 + @Test("Updates shared session with new tokens after refresh") 140 + func updatesSharedSession() async throws { 141 + let session = try testSession() 142 + let newAccessToken = OAuth.AccessToken(rawValue: "new-access-token") 143 + let newRefreshToken = OAuth.RefreshToken(rawValue: "new-refresh-token") 144 + 145 + @Shared(.inMemory("updatesSharedSession.session")) var sharedSession: OAuth.Session? = session 146 + 147 + actor Counter { var count = 0; func increment() { count += 1 } } 148 + let counter = Counter() 149 + 150 + try await withDependencies { 151 + $0.httpClient = .liveValue 152 + $0.getDefaultSession = { [$sharedSession] in 153 + $sharedSession.wrappedValue ?? .placeholder } 154 + $0[OAuth.Client.self].refreshToken = { _ in 155 + try JSONDecoder().decode( 156 + OAuth.Token.Response.self, 157 + from: tokenRefreshResponseData( 158 + accessToken: newAccessToken.rawValue, 159 + refreshToken: newRefreshToken.rawValue 160 + ) 161 + ) 162 + } 163 + $0.errorInterceptors = [.tokenRefresh(session: $sharedSession)] 164 + $0.dataForURL = { _, _ in 165 + await counter.increment() 166 + if await counter.count == 1 { 167 + let body = #"{"error":"invalid_token","message":"\"exp\" claim timestamp check failed"}"#.data(using: .utf8)! 168 + return (body, HTTPResponse(status: .unauthorized)) 169 + } 170 + return (Data("{}".utf8), HTTPResponse(status: .ok)) 171 + } 172 + } operation: { 173 + try await session.withAuthentication { 174 + @Dependency(\.httpClient) var httpClient 175 + let _: CodableVoid = try await httpClient.get(testURL) 176 + } 177 + } 178 + 179 + #expect(sharedSession?.accessToken == newAccessToken) 180 + #expect(sharedSession?.refreshToken == newRefreshToken) 181 + } 182 + 183 + @Test("Does not intercept non-invalid_token 401 errors") 184 + func doesNotInterceptOther401() async throws { 185 + let session = try testSession() 186 + 187 + @Shared(.inMemory("doesNotInterceptOther401.session")) var sharedSession: OAuth.Session? = session 188 + actor Flag { var value = false; func set() { value = true } } 189 + let refreshCalled = Flag() 190 + 191 + do { 192 + try await withDependencies { 193 + $0.httpClient = .liveValue 194 + $0.getDefaultSession = { [$sharedSession] in 195 + $sharedSession.wrappedValue ?? .placeholder } 196 + $0[OAuth.Client.self].refreshToken = { _ in 197 + await refreshCalled.set() 198 + throw CancellationError() 199 + } 200 + $0.errorInterceptors = [.tokenRefresh(session: $sharedSession)] 201 + $0.dataForURL = { _, _ in 202 + let body = #"{"error":"InvalidRequest","message":"some other error"}"#.data(using: .utf8)! 203 + return (body, HTTPResponse(status: .unauthorized)) 204 + } 205 + } operation: { 206 + try await session.withAuthentication { 207 + @Dependency(\.httpClient) var httpClient 208 + let _: CodableVoid = try await httpClient.get(testURL) 209 + } 210 + } 211 + } catch {} 212 + 213 + #expect(await !refreshCalled.value) 214 + } 215 + 216 + @Test("Does not intercept non-401 failures") 217 + func doesNotInterceptNon401() async throws { 218 + let session = try testSession() 219 + 220 + @Shared(.inMemory("doesNotInterceptNon401.session")) var sharedSession: OAuth.Session? = session 221 + actor Flag { var value = false; func set() { value = true } } 222 + let refreshCalled = Flag() 223 + 224 + do { 225 + try await withDependencies { [$sharedSession] in 226 + $0.httpClient = .liveValue 227 + $0.getDefaultSession = { $sharedSession.wrappedValue ?? .placeholder } 228 + $0[OAuth.Client.self].refreshToken = { _ in 229 + await refreshCalled.set() 230 + throw CancellationError() 231 + } 232 + $0.errorInterceptors = [.tokenRefresh(session: $sharedSession)] 233 + $0.dataForURL = { _, _ in 234 + let body = #"{"error":"invalid_token","message":"exp check"}"#.data(using: .utf8)! 235 + return (body, HTTPResponse(status: .forbidden)) 236 + } 237 + } operation: { 238 + try await session.withAuthentication { 239 + @Dependency(\.httpClient) var httpClient 240 + let response: CodableVoid = try await httpClient.get(testURL) 241 + } 242 + } 243 + } catch {} 244 + 245 + #expect(await !refreshCalled.value) 246 + } 247 + }